bitboss-ui 3.0.0-beta.56 → 3.0.0-beta.57

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 (229) hide show
  1. package/README.md +24 -0
  2. package/bin/bitboss-ui-mcp.mjs +14 -5
  3. package/bin/bitboss-ui.mjs +6 -1
  4. package/dist/ai/BbBreadcrumbs.md +1 -1
  5. package/dist/ai/BbCheckbox.md +1 -1
  6. package/dist/ai/BbCheckboxGroup.md +1 -1
  7. package/dist/ai/BbColorInput.md +2 -2
  8. package/dist/ai/BbColorPalette.md +1 -1
  9. package/dist/ai/BbConfirm.md +1 -1
  10. package/dist/ai/BbDatePicker.md +1 -1
  11. package/dist/ai/BbDatePickerInput.md +2 -2
  12. package/dist/ai/BbDialog.md +438 -197
  13. package/dist/ai/BbDropdown.md +1 -1
  14. package/dist/ai/BbDropdownButton.md +1 -1
  15. package/dist/ai/BbDropzone.md +1 -1
  16. package/dist/ai/BbForm.md +7 -4
  17. package/dist/ai/BbHost.md +1 -1
  18. package/dist/ai/BbNumberInput.md +1 -1
  19. package/dist/ai/BbOffCanvas.md +323 -186
  20. package/dist/ai/BbPopover.md +1 -1
  21. package/dist/ai/BbRadio.md +1 -1
  22. package/dist/ai/BbRadioGroup.md +1 -1
  23. package/dist/ai/BbRating.md +1 -1
  24. package/dist/ai/BbSelect.md +2 -2
  25. package/dist/ai/BbSelectPopover.md +8 -9
  26. package/dist/ai/BbSlider.md +1 -1
  27. package/dist/ai/BbSwitch.md +1 -1
  28. package/dist/ai/BbSwitchGroup.md +1 -1
  29. package/dist/ai/BbTag.md +1 -1
  30. package/dist/ai/BbTextInput.md +1 -1
  31. package/dist/ai/BbTextarea.md +1 -1
  32. package/dist/ai/BbTimePicker.md +1 -1
  33. package/dist/ai/BbTimePickerInput.md +2 -2
  34. package/dist/ai/changelog.json +110 -4
  35. package/dist/ai/components.json +35 -34
  36. package/dist/ai/composables/useBbDialogContext.md +3 -2
  37. package/dist/ai/composables/useBbFormContext.md +3 -1
  38. package/dist/ai/composables/useBbOffCanvasContext.md +2 -1
  39. package/dist/ai/guides/eslint-rules.md +2 -0
  40. package/dist/ai/guides/installation-and-plugin-setup.md +1 -0
  41. package/dist/ai/guides/migration/components/bb-dialog.md +13 -12
  42. package/dist/ai/guides/migration/components/bb-offcanvas.md +10 -9
  43. package/dist/ai/guides/migration/v2-to-v3.md +35 -0
  44. package/dist/ai/helpers/inertia/manifest.json +1 -1
  45. package/dist/ai/manifest/components/BbBreadcrumbs.tree.json +1 -1
  46. package/dist/ai/manifest/components/BbCheckbox.json +1 -1
  47. package/dist/ai/manifest/components/BbCheckboxGroup.json +1 -1
  48. package/dist/ai/manifest/components/BbColorInput.json +2 -2
  49. package/dist/ai/manifest/components/BbColorInput.tree.json +1 -1
  50. package/dist/ai/manifest/components/BbColorPalette.json +1 -1
  51. package/dist/ai/manifest/components/BbColorPalette.tree.json +1 -1
  52. package/dist/ai/manifest/components/BbConfirm.tree.json +1 -1
  53. package/dist/ai/manifest/components/BbDatePicker.json +1 -1
  54. package/dist/ai/manifest/components/BbDatePicker.tree.json +1 -1
  55. package/dist/ai/manifest/components/BbDatePickerInput.json +2 -2
  56. package/dist/ai/manifest/components/BbDatePickerInput.tree.json +1 -1
  57. package/dist/ai/manifest/components/BbDialog.json +18 -9
  58. package/dist/ai/manifest/components/BbDialog.tree.json +1 -1
  59. package/dist/ai/manifest/components/BbDropdown.json +1 -1
  60. package/dist/ai/manifest/components/BbDropdown.tree.json +1 -1
  61. package/dist/ai/manifest/components/BbDropdownButton.json +1 -1
  62. package/dist/ai/manifest/components/BbDropdownButton.tree.json +1 -1
  63. package/dist/ai/manifest/components/BbDropzone.json +1 -1
  64. package/dist/ai/manifest/components/BbHost.tree.json +1 -1
  65. package/dist/ai/manifest/components/BbNumberInput.json +1 -1
  66. package/dist/ai/manifest/components/BbOffCanvas.json +17 -8
  67. package/dist/ai/manifest/components/BbOffCanvas.tree.json +10 -0
  68. package/dist/ai/manifest/components/BbPopover.json +1 -1
  69. package/dist/ai/manifest/components/BbPopover.tree.json +1 -1
  70. package/dist/ai/manifest/components/BbRadio.json +1 -1
  71. package/dist/ai/manifest/components/BbRadioGroup.json +1 -1
  72. package/dist/ai/manifest/components/BbRating.json +1 -1
  73. package/dist/ai/manifest/components/BbSelect.json +2 -2
  74. package/dist/ai/manifest/components/BbSelect.tree.json +1 -1
  75. package/dist/ai/manifest/components/BbSelectPopover.json +1 -1
  76. package/dist/ai/manifest/components/BbSelectPopover.tree.json +1 -1
  77. package/dist/ai/manifest/components/BbSlider.json +1 -1
  78. package/dist/ai/manifest/components/BbSwitch.json +1 -1
  79. package/dist/ai/manifest/components/BbSwitchGroup.json +1 -1
  80. package/dist/ai/manifest/components/BbTag.json +1 -1
  81. package/dist/ai/manifest/components/BbTextInput.json +1 -1
  82. package/dist/ai/manifest/components/BbTextarea.json +1 -1
  83. package/dist/ai/manifest/components/BbTimePicker.json +1 -1
  84. package/dist/ai/manifest/components/BbTimePicker.tree.json +1 -1
  85. package/dist/ai/manifest/components/BbTimePickerInput.json +2 -2
  86. package/dist/ai/manifest/components/BbTimePickerInput.tree.json +1 -1
  87. package/dist/ai/manifest/index.json +2 -2
  88. package/dist/ai/manifest/meta.json +1 -0
  89. package/dist/ai/manifest/types.advanced.json +2 -2
  90. package/dist/ai/manifest/types.api.json +2 -1
  91. package/dist/ai/recipes/inertia/approvals-inbox.md +13 -12
  92. package/dist/ai/recipes/inertia/record-form.md +6 -33
  93. package/dist/ai/recipes/inertia/records-workspace.md +0 -1
  94. package/dist/ai/recipes/inertia/settings-workspace.md +107 -137
  95. package/dist/ai/recipes/nuxt/approvals-inbox.md +13 -12
  96. package/dist/ai/recipes/nuxt/record-form.md +6 -33
  97. package/dist/ai/recipes/nuxt/records-workspace.md +0 -1
  98. package/dist/ai/recipes/nuxt/settings-workspace.md +107 -138
  99. package/dist/ai/recipes/vue/approvals-inbox.md +13 -12
  100. package/dist/ai/recipes/vue/record-form.md +6 -33
  101. package/dist/ai/recipes/vue/records-workspace.md +0 -1
  102. package/dist/ai/recipes/vue/settings-workspace.md +107 -138
  103. package/dist/ai/source/BbButton.md +13 -3
  104. package/dist/ai/source/BbCheckbox.md +3 -1
  105. package/dist/ai/source/BbCheckboxGroup.md +3 -1
  106. package/dist/ai/source/BbColorInput.md +3 -1
  107. package/dist/ai/source/BbDatePickerInput.md +3 -1
  108. package/dist/ai/source/BbDialog.md +156 -18
  109. package/dist/ai/source/BbDropzone.md +3 -1
  110. package/dist/ai/source/BbForm.md +13 -0
  111. package/dist/ai/source/BbNumberInput.md +3 -1
  112. package/dist/ai/source/BbOffCanvas.md +714 -21
  113. package/dist/ai/source/BbRadio.md +6 -2
  114. package/dist/ai/source/BbRadioGroup.md +3 -1
  115. package/dist/ai/source/BbRating.md +3 -1
  116. package/dist/ai/source/BbSelect.md +3 -1
  117. package/dist/ai/source/BbSlider.md +7 -4
  118. package/dist/ai/source/BbSwitch.md +2 -0
  119. package/dist/ai/source/BbSwitchGroup.md +2 -0
  120. package/dist/ai/source/BbTag.md +3 -1
  121. package/dist/ai/source/BbTextInput.md +3 -1
  122. package/dist/ai/source/BbTextarea.md +3 -1
  123. package/dist/ai/source/BbTimePickerInput.md +3 -1
  124. package/dist/components/BbBaseButton/BbBaseButton.vue_vue_type_script_setup_true_lang.js +5 -5
  125. package/dist/components/BbButton/BbButton.vue_vue_type_script_setup_true_lang.js +75 -74
  126. package/dist/components/BbCheckbox/BbCheckbox.vue_vue_type_script_setup_true_lang.js +90 -89
  127. package/dist/components/BbCheckbox/types.d.ts +1 -1
  128. package/dist/components/BbCheckboxGroup/BbCheckboxGroup.vue_vue_type_script_setup_true_lang.js +91 -90
  129. package/dist/components/BbCheckboxGroup/types.d.ts +1 -1
  130. package/dist/components/BbColorInput/BbColorInput.vue_vue_type_script_setup_true_lang.js +69 -68
  131. package/dist/components/BbColorInput/types.d.ts +1 -1
  132. package/dist/components/BbDatePickerInput/BbDatePickerInput.vue_vue_type_script_setup_true_lang.js +288 -287
  133. package/dist/components/BbDatePickerInput/types.d.ts +1 -1
  134. package/dist/components/BbDialog/BbDialog.vue.d.ts +9 -0
  135. package/dist/components/BbDialog/BbDialog.vue_vue_type_script_setup_true_lang.js +233 -189
  136. package/dist/components/BbDialog/types.d.ts +58 -5
  137. package/dist/components/BbDropzone/BbDropzone.vue_vue_type_script_setup_true_lang.js +150 -149
  138. package/dist/components/BbDropzone/types.d.ts +1 -1
  139. package/dist/components/BbForm/BbForm.vue_vue_type_script_setup_true_lang.js +154 -148
  140. package/dist/components/BbNumberInput/BbNumberInput.vue_vue_type_script_setup_true_lang.js +143 -142
  141. package/dist/components/BbNumberInput/types.d.ts +1 -1
  142. package/dist/components/BbOffCanvas/BbOffCanvas.vue.d.ts +10 -0
  143. package/dist/components/BbOffCanvas/BbOffCanvas.vue_vue_type_script_setup_true_lang.js +495 -255
  144. package/dist/components/BbOffCanvas/sheetSnap.d.ts +64 -0
  145. package/dist/components/BbOffCanvas/sheetSnap.js +58 -0
  146. package/dist/components/BbOffCanvas/types.d.ts +59 -5
  147. package/dist/components/BbPagination/BbPagination.vue_vue_type_script_setup_true_lang.js +68 -68
  148. package/dist/components/BbRadio/BbRadio.vue_vue_type_script_setup_true_lang.js +66 -65
  149. package/dist/components/BbRadio/types.d.ts +1 -1
  150. package/dist/components/BbRadioGroup/BbRadioGroup.vue_vue_type_script_setup_true_lang.js +96 -95
  151. package/dist/components/BbRadioGroup/types.d.ts +1 -1
  152. package/dist/components/BbRating/BbRating.vue_vue_type_script_setup_true_lang.js +79 -78
  153. package/dist/components/BbRating/types.d.ts +1 -1
  154. package/dist/components/BbSelect/BbSelect.vue_vue_type_script_setup_true_lang.js +153 -152
  155. package/dist/components/BbSelect/types.d.ts +1 -1
  156. package/dist/components/BbSlider/BbSlider.vue_vue_type_script_setup_true_lang.js +106 -105
  157. package/dist/components/BbSlider/types.d.ts +1 -1
  158. package/dist/components/BbSwitch/BbSwitch.vue_vue_type_script_setup_true_lang.js +86 -85
  159. package/dist/components/BbSwitchGroup/BbSwitchGroup.vue_vue_type_script_setup_true_lang.js +131 -130
  160. package/dist/components/BbTag/BbTag.vue_vue_type_script_setup_true_lang.js +64 -63
  161. package/dist/components/BbTag/types.d.ts +1 -1
  162. package/dist/components/BbTextInput/BbTextInput.vue_vue_type_script_setup_true_lang.js +71 -70
  163. package/dist/components/BbTextInput/types.d.ts +1 -1
  164. package/dist/components/BbTextarea/BbTextarea.vue_vue_type_script_setup_true_lang.js +81 -80
  165. package/dist/components/BbTextarea/types.d.ts +1 -1
  166. package/dist/components/BbTimePickerInput/BbTimePickerInput.vue_vue_type_script_setup_true_lang.js +169 -168
  167. package/dist/components/BbTimePickerInput/types.d.ts +1 -1
  168. package/dist/composables/hostOpenings.js +1 -1
  169. package/dist/composables/overlayRegistry.d.ts +36 -1
  170. package/dist/composables/overlayRegistry.js +167 -101
  171. package/dist/composables/useBbDialogContext.d.ts +17 -3
  172. package/dist/composables/useBbDialogContext.js +30 -27
  173. package/dist/composables/useBbFormContext.d.ts +7 -0
  174. package/dist/composables/useBbFormContext.js +40 -34
  175. package/dist/composables/useBbTabsContext.js +140 -171
  176. package/dist/composables/useKeyboardInset.js +10 -20
  177. package/dist/composables/useOverlayNavigation.d.ts +37 -0
  178. package/dist/composables/useOverlayNavigation.js +89 -0
  179. package/dist/composables/useUrlQueryParam.d.ts +21 -0
  180. package/dist/composables/useUrlQueryParam.js +48 -0
  181. package/dist/directives/overlayTrigger.js +41 -41
  182. package/dist/i18n/locales/cs.json +4 -1
  183. package/dist/i18n/locales/da.json +4 -1
  184. package/dist/i18n/locales/de-CH.json +4 -1
  185. package/dist/i18n/locales/de.json +4 -1
  186. package/dist/i18n/locales/el.json +4 -1
  187. package/dist/i18n/locales/en-GB.json +4 -1
  188. package/dist/i18n/locales/en.json +4 -1
  189. package/dist/i18n/locales/es-MX.json +4 -1
  190. package/dist/i18n/locales/es.json +4 -1
  191. package/dist/i18n/locales/fi.json +4 -1
  192. package/dist/i18n/locales/fr.json +4 -1
  193. package/dist/i18n/locales/hu.json +4 -1
  194. package/dist/i18n/locales/it.json +4 -1
  195. package/dist/i18n/locales/ja.json +4 -1
  196. package/dist/i18n/locales/ko.json +4 -1
  197. package/dist/i18n/locales/nb.json +4 -1
  198. package/dist/i18n/locales/nl.json +4 -1
  199. package/dist/i18n/locales/pl.json +4 -1
  200. package/dist/i18n/locales/pt-BR.json +4 -1
  201. package/dist/i18n/locales/pt-PT.json +4 -1
  202. package/dist/i18n/locales/ro.json +4 -1
  203. package/dist/i18n/locales/ru.json +4 -1
  204. package/dist/i18n/locales/sv.json +4 -1
  205. package/dist/i18n/locales/tr.json +4 -1
  206. package/dist/i18n/locales/uk.json +4 -1
  207. package/dist/i18n/locales/zh-CN.json +4 -1
  208. package/dist/i18n/locales/zh-HK.json +4 -1
  209. package/dist/i18n/locales/zh-TW.json +4 -1
  210. package/dist/index.d.ts +1 -1
  211. package/dist/index.js +73 -73
  212. package/dist/llms-full.txt +1250 -999
  213. package/dist/llms-medium.txt +1 -0
  214. package/dist/locale-blueprint.json +4 -1
  215. package/dist/plugin.js +28 -27
  216. package/dist/styles.css +2 -2
  217. package/dist/utilities/functions/isTextEntry.d.ts +5 -0
  218. package/dist/utilities/functions/isTextEntry.js +15 -0
  219. package/dist/utilities/misc/autofocus.d.ts +20 -0
  220. package/dist/utilities/misc/autofocus.js +11 -0
  221. package/dist/utils/copyData.d.ts +8 -5
  222. package/dist/utils/copyData.js +6 -6
  223. package/dist/utils/versionCheck.js +1 -1
  224. package/package.json +1 -1
  225. package/scripts/lib/eslint-disable.mjs +19 -0
  226. package/scripts/lib/eslint-plugin.d.ts +2 -0
  227. package/scripts/lib/eslint-plugin.mjs +57 -0
  228. package/scripts/lib/prefer-v-bb-dialog.mjs +455 -0
  229. package/scripts/lib/validate-bb-markup.mjs +11 -1
@@ -48,9 +48,10 @@ Pick a sibling instead when the shape fits it better:
48
48
  `useConfirm`, not a hand-built dialog.
49
49
  - A small anchored menu or bit of interactive content → `BbPopover`.
50
50
 
51
- `BbDialog` and `BbOffCanvas` share most of their API (v-model, `title`, the
52
- `default`/`footer` slots, `persistent`, `focus-target`, `eager`, `stack`,
53
- `transition-duration`), so moving a surface between them is mostly a tag swap.
51
+ `BbDialog` and `BbOffCanvas` share most of their API (`id`, v-model, `title`,
52
+ the `default`/`footer` slots, `default-data`, `navigation`, `persistent`,
53
+ `focus-target`, `eager`, `stack`, `transition-duration`), so moving a surface
54
+ between them is mostly a tag swap.
54
55
 
55
56
  ### Open and close with v-model
56
57
 
@@ -91,21 +92,29 @@ buttons and is refused while `prevent-close` is on.
91
92
  </div>
92
93
  <!-- `v-model` on purpose: this is the guide's "open and close with
93
94
  v-model" example. Without that lesson, `v-bb-dialog` opens it by id. -->
94
- <BbDialog v-model="open" title="Create label" @show="reset">
95
- <div class="grid gap-3">
96
- <BbTextInput
97
- v-model="name"
98
- compact
99
- label="Label name"
100
- placeholder="e.g. Priority"
101
- />
102
- <p class="m-0 text-xs text-(--bb-text-muted)">
103
- Names are unique per workspace and show up in filters.
104
- </p>
105
- </div>
106
- <template #footer>
95
+ <!-- eslint-disable-next-line bitboss-ui/prefer-v-bb-dialog -->
96
+ <BbDialog v-model="open" :default-data="{ name: '' }" title="Create label">
97
+ <template #default="{ data }">
98
+ <div class="grid gap-3">
99
+ <BbTextInput
100
+ v-model="data.name"
101
+ compact
102
+ label="Label name"
103
+ placeholder="e.g. Priority"
104
+ />
105
+ <p class="m-0 text-xs text-(--bb-text-muted)">
106
+ Names are unique per workspace and show up in filters.
107
+ </p>
108
+ </div>
109
+ </template>
110
+ <template #footer="{ data }">
107
111
  <BbButton variant="ghost" @click="open = false">Cancel</BbButton>
108
- <BbButton :disabled="!name?.trim()" variant="primary" @click="create">
112
+ <!-- Persist the new label here, then close through the model. -->
113
+ <BbButton
114
+ :disabled="!data.name?.trim()"
115
+ variant="primary"
116
+ @click="open = false"
117
+ >
109
118
  Create
110
119
  </BbButton>
111
120
  </template>
@@ -117,17 +126,6 @@ import { ref } from 'vue';
117
126
  import { BbButton, BbDialog, BbTextInput } from 'bitboss-ui';
118
127
 
119
128
  const open = ref(false);
120
- const name = ref<string | null>('');
121
-
122
- // Re-seed on `@show`, not on close — a dismissed dialog may be reopened.
123
- const reset = () => {
124
- name.value = '';
125
- };
126
-
127
- const create = () => {
128
- // Persist the new label here, then close.
129
- open.value = false;
130
- };
131
129
  </script>
132
130
  ```
133
131
 
@@ -211,9 +209,9 @@ reads `data` in two places:
211
209
  `data` stays set until the close transition has run, so the content never
212
210
  flashes empty while the dialog fades out. The edit-record example in
213
211
  [Header, footer and body](#header-footer-and-body) below works this way: each
214
- row's Edit button passes its contact, the script reads it as
215
- `useBbDialogContext<Contact>('edit-contact').data`, and Save closes with
216
- `hide()`.
212
+ row's Edit button passes its contact, the slots read it as `data`, and Save
213
+ closes with `complete(data)`, handing the edited copy to the row's
214
+ `onComplete`.
217
215
 
218
216
  The rules:
219
217
 
@@ -244,10 +242,15 @@ The rules:
244
242
  `v-bb-dialog` click whose own handler renders the dialog through a `v-if`,
245
243
  is held until the dialog registers, so one click opens it (up to 1s; then
246
244
  it settles `'missing'` with a dev warning, and `onComplete` never runs).
245
+ The warning suggests the nearest registered id for a typo, and says when a
246
+ dialog with that id exists but is out of reach (inside an on-demand dialog,
247
+ which is private, or parked by KeepAlive) or is a `BbOffCanvas`.
247
248
  - **Accessibility.** The button gets `aria-haspopup="dialog"`,
248
249
  `aria-expanded` and `aria-controls`, and focus returns to it when the
249
250
  dialog closes. On a phone, the adaptive sheet opens the same way.
250
251
  - **The server** renders the button untouched. The ARIA arrives on mount.
252
+ - **A native `<button commandfor="…" command="show-modal">`** (or `"close"`)
253
+ is safe: it takes the same open and user-close paths. Prefer `v-bb-dialog`.
251
254
 
252
255
  `bitboss-ui check` and the ESLint rule `bitboss-ui/portal-names` report a
253
256
  `v-bb-dialog` or `useBbDialogContext` id that no `<BbDialog id>` in the
@@ -314,17 +317,17 @@ const replace = (saved: Member) => {
314
317
  `{ completed: true, value }`. It wins over `prevent-close`, like `hide()`.
315
318
  - Every other close is a dismissal, `{ completed: false, reason }`:
316
319
 
317
- | `reason` | When |
318
- | ------------------------- | -------------------------------------------------------------------------------------------- |
319
- | `'escape'` / `'backdrop'` | Escape, a backdrop click |
320
- | `'close'` | the ✕ or the slots' `close()` |
321
- | `'swipe'` | the phone sheet swiped away |
322
- | `'hide'` | `hide()`, or the model set to `false` (also a bound `v-model` whose parent refused the open) |
323
- | `'replaced'` | a newer `show()` on the open dialog took over |
324
- | `'missing'` | no dialog with that id mounted within 1s |
325
- | `'unmounted'` | the dialog unmounted (or KeepAlive parked it), or a waiting `show()`'s caller went away |
326
- | `'failed'` | an on-demand dialog could not render |
327
- | `'server'` | `show()` called during a server render: nothing opens there |
320
+ | `reason` | When |
321
+ | ------------------------- | ---------------------------------------------------------------------------------------------------------------- |
322
+ | `'escape'` / `'backdrop'` | the system's close request (Esc, Android back, Back on a [URL dialog](#in-the-url-navigation)), a backdrop click |
323
+ | `'close'` | the ✕ or the slots' `close()` |
324
+ | `'swipe'` | the phone sheet swiped away |
325
+ | `'hide'` | `hide()`, or the model set to `false` (also a bound `v-model` whose parent refused the open) |
326
+ | `'replaced'` | a newer `show()` on the open dialog took over |
327
+ | `'missing'` | no dialog with that id mounted within 1s |
328
+ | `'unmounted'` | the dialog unmounted (or KeepAlive parked it), or a waiting `show()`'s caller went away |
329
+ | `'failed'` | an on-demand dialog could not render |
330
+ | `'server'` | `show()` called during a server render: nothing opens there |
328
331
 
329
332
  - On Android, the system back button closes the top dialog like Escape (it is the browser's close request), so `prevent-close` and a saving form refuse it the same way; iOS swipe-back and the desktop back button are page navigation.
330
333
  - It settles when the close **starts**, together with `@hide`, so a list can
@@ -358,6 +361,116 @@ a `File`, a `Map`) is the same object you passed. So:
358
361
  same copy, and it is reactive: a title bound to `data.name` follows the
359
362
  typing.
360
363
 
364
+ #### Create and edit in one dialog: `default-data`
365
+
366
+ Opened without data (a create), `data` is `undefined`. A
367
+ `<BbForm :model="data">` copes (the form starts blank), but `v-model="data.name"`
368
+ or `{{ data.name }}` throws. Give the dialog a `default-data` object: every
369
+ opening that passes no data gets a fresh copy of it, so the fields bind
370
+ `data.x` with no guard. An edit lays its row over the default
371
+ (`{ ...default, ...row }`, one level), so a field the row lacks starts from the
372
+ default.
373
+
374
+ **Create and edit with default-data**
375
+
376
+ ```vue
377
+ <template>
378
+ <div class="flex w-full max-w-sm flex-col gap-2">
379
+ <ul class="m-0 flex list-none flex-col gap-2 p-0">
380
+ <li
381
+ v-for="member in members"
382
+ :key="member.id"
383
+ class="flex items-center justify-between gap-3 rounded-(--bb-radius) border p-3"
384
+ >
385
+ <div class="min-w-0">
386
+ <p class="m-0 truncate text-sm font-medium">{{ member.name }}</p>
387
+ <p class="m-0 truncate text-xs text-(--bb-text-muted)">
388
+ {{ member.email }}{{ member.admin ? ' · admin' : '' }}
389
+ </p>
390
+ </div>
391
+ <!-- An edit: the row is laid over the default. -->
392
+ <BbButton
393
+ v-bb-dialog="{ id: 'member', data: member, onComplete: save }"
394
+ :aria-label="`Edit ${member.name}`"
395
+ size="sm"
396
+ variant="outline"
397
+ >
398
+ Edit
399
+ </BbButton>
400
+ </li>
401
+ </ul>
402
+ <!-- A create: no data, so `data` is a fresh copy of the default. -->
403
+ <BbButton
404
+ v-bb-dialog="{ id: 'member', onComplete: save }"
405
+ prepend:icon="lucide:plus"
406
+ variant="outline"
407
+ >
408
+ Add member
409
+ </BbButton>
410
+ </div>
411
+ <!-- `data` is never undefined: the fields bind it with no guard. -->
412
+ <BbDialog id="member" :default-data="NEW_MEMBER" size="md" title="Member">
413
+ <template #title="{ data }">
414
+ {{ data.id ? `Edit ${data.name}` : 'New member' }}
415
+ </template>
416
+ <template #default="{ data }">
417
+ <div class="grid gap-3">
418
+ <BbTextInput v-model="data.name" compact label="Name" />
419
+ <BbTextInput v-model="data.email" compact label="Email" type="email" />
420
+ <BbSwitch v-model="data.admin" label="Admin" />
421
+ </div>
422
+ </template>
423
+ <template #footer="{ data, close, complete, reset }">
424
+ <!-- `reset()` restores the default, on an edit too: it clears. -->
425
+ <BbButton variant="ghost" @click="reset">Clear</BbButton>
426
+ <BbButton variant="ghost" @click="close">Cancel</BbButton>
427
+ <BbButton
428
+ :disabled="!data.name.trim()"
429
+ variant="primary"
430
+ @click="complete(data)"
431
+ >
432
+ Save
433
+ </BbButton>
434
+ </template>
435
+ </BbDialog>
436
+ </template>
437
+
438
+ <script setup lang="ts">
439
+ import { ref } from 'vue';
440
+ import { BbButton, BbDialog, BbSwitch, BbTextInput } from 'bitboss-ui';
441
+
442
+ type Member = { id?: number; name: string; email: string; admin: boolean };
443
+
444
+ // A plain object: the dialog copies it for every create.
445
+ const NEW_MEMBER: Member = { name: '', email: '', admin: false };
446
+
447
+ // The records — in a real app these come from your store/API.
448
+ const members = ref<Member[]>([
449
+ { id: 1, name: 'Ada Lovelace', email: 'ada@analytical.co', admin: true },
450
+ { id: 2, name: 'Grace Hopper', email: 'grace@navy.mil', admin: false },
451
+ ]);
452
+
453
+ // Persist here: an id updates that row, none adds one.
454
+ const save = (saved: Member) => {
455
+ if (saved.id === undefined)
456
+ members.value.push({ ...saved, id: members.value.length + 1 });
457
+ else
458
+ members.value = members.value.map((member) =>
459
+ member.id === saved.id ? saved : member
460
+ );
461
+ };
462
+ </script>
463
+ ```
464
+
465
+ - **`reset()`** (the slots', or `useBbDialogContext(id).reset()`) restores a
466
+ fresh copy of `default-data`, on an edit too: it clears the form, it doesn't
467
+ go back to the row. Cancel is what discards an edit.
468
+ - **Every close resets it** as well: nothing to do on `@show` or `@hidden`.
469
+ - **An object, not a function.** The dialog copies it every time and never
470
+ writes it, so a module constant is safe.
471
+ - An edit-only dialog, always opened with a row, needs no `default-data` and no
472
+ guard: its body exists only while an opening holds data.
473
+
361
474
  **Whose surroundings the dialog takes.** The directive sits on the trigger in
362
475
  the template, so the dialog it opens follows the trigger's surroundings: a
363
476
  `<BbPtScope>` around the button themes it. A context call (`edit.show()`) is
@@ -470,7 +583,9 @@ const createForDesign = async () => {
470
583
  instead.
471
584
  - **Each opening gets its own DOM id**, so two stacked instances stay valid
472
585
  HTML; `aria-controls` on the trigger follows it. A template dialog keeps the
473
- `id` you gave it.
586
+ `id` you gave it. To find one in an e2e test, use its role and title,
587
+ e.g. `getByRole('dialog', { name: 'Edit account' })`: its HTML id is
588
+ generated per opening.
474
589
  - **It must render its dialog.** A component that renders no `<BbDialog>` /
475
590
  `<BbOffCanvas>`, or whose `useBbDialogContext(id)` names another id than its
476
591
  dialog's, settles `'failed'` with a dev warning instead of hanging. So does a
@@ -528,7 +643,7 @@ with `complete(data)` for the row's `onComplete`.
528
643
  directly, Cancel throws it away, Save returns it. -->
529
644
  <BbDialog id="edit-contact" size="md" title="Edit contact">
530
645
  <template #default="{ data }">
531
- <div v-if="data" class="grid gap-3">
646
+ <div class="grid gap-3">
532
647
  <BbTextInput v-model="data.name" compact label="Full name" />
533
648
  <BbTextInput v-model="data.email" compact label="Email" type="email" />
534
649
  <BbTextInput
@@ -663,6 +778,32 @@ use the **`title` slot**: it replaces the title text alone, so the ✕, the
663
778
  </BbDialog>
664
779
  ```
665
780
 
781
+ **The `title` prop can't see `data`.** It is bound in the page's template,
782
+ where the opening's `data` does not exist. A title about the record goes in
783
+ the `title` slot, which receives `data` like the other slots (so does the
784
+ `description` slot):
785
+
786
+ ```vue
787
+ <template>
788
+ <BbButton v-bb-dialog="{ id: 'rename', data: { name: 'Roadmap' } }">
789
+ Rename
790
+ </BbButton>
791
+ <BbDialog id="rename">
792
+ <template #title="{ data }">Rename “{{ data.name }}”</template>
793
+ <template #default="{ data, complete }">
794
+ <BbForm :model="data" @submit="complete(data)">
795
+ <BbTextInput label="Name" name="name" />
796
+ <BbButton type="submit">Rename</BbButton>
797
+ </BbForm>
798
+ </template>
799
+ </BbDialog>
800
+ </template>
801
+ ```
802
+
803
+ `data` is a reactive copy, so the title follows the typing; to show the name
804
+ as it was, keep it apart (`data: { project, name: project.name }`) or read it
805
+ from the row.
806
+
666
807
  The `header` slot is for the rare header that is not a title: it replaces the
667
808
  **entire** header on both surfaces, ✕ included — you own the close control
668
809
  (the slot hands you `close`) and must wire `titleId` for `aria-labelledby`.
@@ -673,8 +814,9 @@ Give your root element `flex-1` so it fills the header row.
673
814
  The most common dialog is a small form: validate in place, keep the primary
674
815
  action disabled until the input is usable, and close only on success. Cancel
675
816
  must always work — never gate it on validity. This companion disables **Send
676
- invite** until the email is plausible, and resets its fields on `@show` so a
677
- dismissed invite doesn't resurface half-typed:
817
+ invite** until the email is plausible, and binds its fields to `data`, a fresh
818
+ copy of `default-data` on every opening, so a dismissed invite never resurfaces
819
+ half-typed:
678
820
 
679
821
  **Invite a teammate**
680
822
 
@@ -699,28 +841,37 @@ dismissed invite doesn't resurface half-typed:
699
841
  </BbButton>
700
842
  </div>
701
843
  </div>
702
- <BbDialog id="invite-teammate" title="Invite a teammate" @show="reset">
703
- <div class="grid gap-3">
704
- <BbTextInput
705
- v-model="email"
706
- compact
707
- label="Email address"
708
- placeholder="teammate@company.com"
709
- type="email"
710
- />
711
- <BbSelect v-model="role" compact :items="roles" label="Role" />
712
- <p class="m-0 text-xs text-(--bb-text-muted)">
713
- They'll get an email with a link that expires in 7 days.
714
- </p>
715
- </div>
716
- <template #footer="{ close }">
844
+ <!-- `default-data` is the blank invite: every opening starts from a fresh
845
+ copy, so a dismissed invite never resurfaces half-typed. -->
846
+ <BbDialog
847
+ id="invite-teammate"
848
+ :default-data="{ email: '', role: 'member' }"
849
+ title="Invite a teammate"
850
+ >
851
+ <template #default="{ data }">
852
+ <div class="grid gap-3">
853
+ <BbTextInput
854
+ v-model="data.email"
855
+ compact
856
+ label="Email address"
857
+ placeholder="teammate@company.com"
858
+ type="email"
859
+ />
860
+ <BbSelect v-model="data.role" compact :items="roles" label="Role" />
861
+ <p class="m-0 text-xs text-(--bb-text-muted)">
862
+ They'll get an email with a link that expires in 7 days.
863
+ </p>
864
+ </div>
865
+ </template>
866
+ <template #footer="{ data, close, complete }">
717
867
  <!-- Cancel is never gated on validity. -->
718
868
  <BbButton variant="ghost" @click="close">Cancel</BbButton>
869
+ <!-- Send the invite, then close only on success. -->
719
870
  <BbButton
720
- :disabled="!canSend"
871
+ :disabled="!EMAIL.test(data.email ?? '')"
721
872
  prepend:icon="lucide:send"
722
873
  variant="primary"
723
- @click="send"
874
+ @click="complete(data)"
724
875
  >
725
876
  Send invite
726
877
  </BbButton>
@@ -729,14 +880,7 @@ dismissed invite doesn't resurface half-typed:
729
880
  </template>
730
881
 
731
882
  <script setup lang="ts">
732
- import { computed, ref } from 'vue';
733
- import {
734
- BbButton,
735
- BbDialog,
736
- BbSelect,
737
- BbTextInput,
738
- useBbDialogContext,
739
- } from 'bitboss-ui';
883
+ import { BbButton, BbDialog, BbSelect, BbTextInput } from 'bitboss-ui';
740
884
 
741
885
  const roles = [
742
886
  { label: 'Member', value: 'member' },
@@ -744,53 +888,120 @@ const roles = [
744
888
  { label: 'Viewer', value: 'viewer' },
745
889
  ];
746
890
 
747
- const dialog = useBbDialogContext('invite-teammate');
748
- const email = ref<string | null>('');
749
- const role = ref('member');
750
-
751
- // Re-seed on `@show`, not on close — a dismissed dialog may be reopened.
752
- const reset = () => {
753
- email.value = '';
754
- role.value = 'member';
755
- };
756
-
757
891
  // Primary action stays disabled until the email is plausible.
758
- const canSend = computed(() =>
759
- /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value ?? '')
760
- );
761
-
762
- const send = () => {
763
- // Send the invite here, then close only on success.
764
- dialog.hide();
765
- };
892
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
766
893
  </script>
767
894
  ```
768
895
 
769
896
  Two rules keep dialog forms honest:
770
897
 
771
- - Bind the form to `data` (an edit) or start it blank (a create): `data` is
772
- the opening's own copy, so Cancel discards, and every opening, the same row
773
- again included, starts from a fresh one. There is nothing to seed on
774
- `@show` and nothing to clear on `@hidden`. A create opened without data can
775
- still reset its own fields on `@show`, as above.
898
+ - Bind the form to `data`: it is the opening's own copy, so Cancel discards,
899
+ and every opening, the same row again included, starts from a fresh one
900
+ with a fresh body. There is nothing to seed on `@show` and nothing to clear
901
+ on `@hidden`. A create gets `default-data`'s copy, or `undefined` without
902
+ one: then only a `<BbForm :model="data">` starts blank, and anything reading
903
+ `data.x` needs `default-data` (see
904
+ [Create and edit in one dialog](#create-and-edit-in-one-dialog-default-data)).
776
905
  - On success, do the work first, then close: `complete(await api.save(values))`
777
906
  hands the saved record to whoever opened the dialog; `hide()` (or the model
778
907
  set to `false`) closes with no result. The close transition runs while
779
908
  your toast/refresh happens.
780
909
 
910
+ #### A failed save stays open
911
+
912
+ Close only when the save worked. When the server answers with field errors
913
+ (a 422), don't call `complete` or `hide`: hand the errors to the form with
914
+ `useBbFormContext(id).setErrors`, and the dialog stays open with each message
915
+ under its field and focus on the first one. The user fixes it and submits
916
+ again; Cancel still throws the copy away.
917
+
918
+ ```vue
919
+ <script setup lang="ts">
920
+ import {
921
+ BbButton,
922
+ BbDialog,
923
+ BbForm,
924
+ BbTextInput,
925
+ useBbFormContext,
926
+ } from 'bitboss-ui';
927
+
928
+ type Member = { id: number; name: string; email: string };
929
+ type FieldErrors = Record<string, string[]>;
930
+
931
+ // Your API call: the saved member, or the 422's field errors.
932
+ const save = async (member: Member): Promise<Member | FieldErrors> =>
933
+ member.email.endsWith('@example.com')
934
+ ? { ...member }
935
+ : { email: ['Use your work address.'] };
936
+
937
+ const form = useBbFormContext('edit-member-form');
938
+ </script>
939
+
940
+ <template>
941
+ <BbDialog id="edit-member" v-slot="{ data, complete }" title="Edit member">
942
+ <BbForm
943
+ id="edit-member-form"
944
+ :model="data"
945
+ @submit="
946
+ async () => {
947
+ const answer = await save(data);
948
+ if ('id' in answer) complete(answer);
949
+ else form.setErrors(answer);
950
+ }
951
+ "
952
+ >
953
+ <BbTextInput label="Name" name="name" />
954
+ <BbTextInput label="Email" name="email" type="email" />
955
+ <BbButton type="submit">Save</BbButton>
956
+ </BbForm>
957
+ </BbDialog>
958
+ </template>
959
+ ```
960
+
961
+ - **While it saves, the dialog refuses every user close** (the ✕, Cancel's
962
+ `close()`, Escape, the backdrop): the form locks it until the promise
963
+ settles, so the answer never lands on a closed dialog.
964
+ - **The opener sees nothing until it completes.** A failed attempt settles
965
+ no result; `onComplete` (or `await show()`) gets the saved member once a
966
+ later attempt calls `complete`, or a dismissal if the user gives up.
967
+ - A message with no field (Laravel's `message`, a 403) goes under a key no
968
+ field uses, `form.setErrors({ ...errors, _request: [message] })`, and shows in
969
+ the form's `unmatchedErrors` (see BbForm § Server errors).
970
+
781
971
  ### Lifecycle events and lazy content
782
972
 
783
973
  Four events bracket the transitions — `show` fires as the model turns `true`,
784
974
  `shown` after the open transition completes (focus has already moved in at
785
975
  `showModal()`), `hide` as the model turns `false`, and `hidden` after the close
786
976
  transition completes. A surface closed before it finished opening fires no
787
- `shown`. Use
788
- `@show` to reset a create form that was opened without data, `@hidden` to tear
789
- down expensive content.
790
-
791
- Body content is **lazy**: nothing inside the dialog renders until the first
792
- open. Set `eager` to render it immediately (e.g. so a chart can pre-measure, or
793
- tests can query content before opening). `transition-duration` (ms, default
977
+ `shown`.
978
+
979
+ `@show` carries no payload: read the opening's `data` from the slots or
980
+ `useBbDialogContext(id).data`.
981
+
982
+ Every way to close, side by side:
983
+
984
+ | What | Who calls it | Can it be refused? | Result it settles | When it happens |
985
+ | ------------------------------------------------------- | ------------ | ------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------- |
986
+ | the ✕, the slots' `close()` | the user | yes: `prevent-close`, a saving form | `{ completed: false, reason: 'close' }` | on the click |
987
+ | Escape, Back on a URL dialog, a backdrop click, a swipe | the user | yes: `persistent` too | `reason: 'escape'` / `'backdrop'` / `'swipe'` | on the key, Back, click or swipe |
988
+ | `hide()` (context), the model set to `false` | your code | no | `{ completed: false, reason: 'hide' }` | at once |
989
+ | `complete(value)` (slots, context) | your code | no | `{ completed: true, value }` | at once |
990
+ | `@hide` | the dialog | it reports a close, it can't stop one | settled together with it | as any close above **starts** |
991
+ | `@hidden` | the dialog | it reports a close, it can't stop one | already settled | after the close transition; focus returns, `data` clears and the body unmounts then |
992
+
993
+ A refused close fires nothing and settles nothing. Use `@hide` for what
994
+ follows the decision (a toast, a refresh), `@hidden` for teardown.
995
+
996
+ The body lives **per opening**: nothing inside the dialog renders until it
997
+ opens, and the body unmounts after the close transition (right after
998
+ `@hidden`), so the next opening renders it fresh. Inner tabs, scroll and
999
+ touched fields start over, and nothing re-renders against a cleared `data`;
1000
+ state that must outlive an opening belongs in the page. Set `eager` to keep
1001
+ the body mounted from the start and across closes (a chart that pre-measures,
1002
+ tests that query content before opening): `data` still clears while closed (to
1003
+ `undefined`, or the `default-data` copy), so an eager body guards its own
1004
+ reads. `transition-duration` (ms, default
794
1005
  `250`) times both the open/close animation and when `hidden` fires — reopening
795
1006
  during the close transition keeps the dialog open instead of snapping shut.
796
1007
 
@@ -887,32 +1098,40 @@ element.
887
1098
  >
888
1099
  </div>
889
1100
  </div>
1101
+ <!-- Every opening starts from a fresh copy of `default-data`. -->
890
1102
  <BbDialog
891
1103
  id="complete-profile"
1104
+ :default-data="{ displayName: '', company: '' }"
892
1105
  focus-target="input"
893
1106
  hide-close
894
1107
  persistent
895
1108
  title="Complete your profile"
896
- @show="reset"
897
1109
  >
898
- <div class="grid gap-3">
899
- <BbTextInput
900
- v-model="displayName"
901
- compact
902
- label="Display name"
903
- placeholder="Ada Lovelace"
904
- />
905
- <BbTextInput
906
- v-model="company"
907
- compact
908
- label="Company"
909
- placeholder="Acme Inc."
910
- />
911
- </div>
912
- <template #footer="{ close }">
1110
+ <template #default="{ data }">
1111
+ <div class="grid gap-3">
1112
+ <BbTextInput
1113
+ v-model="data.displayName"
1114
+ compact
1115
+ label="Display name"
1116
+ placeholder="Ada Lovelace"
1117
+ />
1118
+ <BbTextInput
1119
+ v-model="data.company"
1120
+ compact
1121
+ label="Company"
1122
+ placeholder="Acme Inc."
1123
+ />
1124
+ </div>
1125
+ </template>
1126
+ <template #footer="{ data, close, complete }">
913
1127
  <!-- Keep a visible way out — never trap the user. -->
914
1128
  <BbButton variant="ghost" @click="close">Skip</BbButton>
915
- <BbButton :disabled="!company?.trim()" variant="primary" @click="save">
1129
+ <!-- Persist the profile, then close with it. -->
1130
+ <BbButton
1131
+ :disabled="!data.company?.trim()"
1132
+ variant="primary"
1133
+ @click="complete(data)"
1134
+ >
916
1135
  Save
917
1136
  </BbButton>
918
1137
  </template>
@@ -920,28 +1139,7 @@ element.
920
1139
  </template>
921
1140
 
922
1141
  <script setup lang="ts">
923
- import { ref } from 'vue';
924
- import {
925
- BbButton,
926
- BbDialog,
927
- BbTextInput,
928
- useBbDialogContext,
929
- } from 'bitboss-ui';
930
-
931
- const dialog = useBbDialogContext('complete-profile');
932
- const displayName = ref<string | null>('');
933
- const company = ref<string | null>('');
934
-
935
- // Re-seed on `@show` so a dismissed step reopens clean.
936
- const reset = () => {
937
- displayName.value = '';
938
- company.value = '';
939
- };
940
-
941
- const save = () => {
942
- // Persist the profile here, then close: `hide()` is the code's close.
943
- dialog.hide();
944
- };
1142
+ import { BbButton, BbDialog, BbTextInput } from 'bitboss-ui';
945
1143
  </script>
946
1144
  ```
947
1145
 
@@ -998,11 +1196,10 @@ form in a popover inside the dialog does not lock the dialog.
998
1196
  </template>
999
1197
  <template #footer="{ data, close }">
1000
1198
  <BbButton variant="ghost" @click="close">Cancel</BbButton>
1001
- <!-- Outside the form, so it binds the save's spinner itself. -->
1199
+ <!-- Outside the form: `form` links it, so it spins with the save. -->
1002
1200
  <BbButton
1003
1201
  :disabled="!data?.name?.trim()"
1004
1202
  form="rename-project-form"
1005
- :loading="form.submitting.value"
1006
1203
  type="submit"
1007
1204
  variant="primary"
1008
1205
  >
@@ -1014,19 +1211,12 @@ form in a popover inside the dialog does not lock the dialog.
1014
1211
 
1015
1212
  <script setup lang="ts">
1016
1213
  import { ref } from 'vue';
1017
- import {
1018
- BbButton,
1019
- BbDialog,
1020
- BbForm,
1021
- BbTextInput,
1022
- useBbFormContext,
1023
- } from 'bitboss-ui';
1214
+ import { BbButton, BbDialog, BbForm, BbTextInput } from 'bitboss-ui';
1024
1215
 
1025
1216
  type Project = { name: string };
1026
1217
 
1027
1218
  // The record being renamed — in a real app this comes from your store/API.
1028
1219
  const project = ref<Project>({ name: 'acme-storefront' });
1029
- const form = useBbFormContext('rename-project-form');
1030
1220
 
1031
1221
  // Your API call: return the promise, and the form (and the dialog around it)
1032
1222
  // stays locked until it settles. It resolves the saved project.
@@ -1128,8 +1318,9 @@ consequences an agent should design around:
1128
1318
  or tab reaches the page behind.
1129
1319
  - Later top-layer surfaces paint above earlier ones: a `BbPopover`, `BbSelect`,
1130
1320
  `BbTooltip` or nested `BbDialog`/`BbConfirm` opened from inside a dialog
1131
- appears above it and works — no special wiring. `BbToast` regions re-home
1132
- themselves into the open modal so toasts fired from a dialog stay clickable.
1321
+ appears above it and works — no special wiring. The toast regions
1322
+ `<BbHost />` renders re-home themselves into the open modal, so toasts fired
1323
+ from a dialog stay clickable.
1133
1324
 
1134
1325
  **Don't teleport overlays out of an open dialog or sheet.** A
1135
1326
  `<Teleport to="body">` (or any `z-index` layer) opened from inside renders
@@ -1163,7 +1354,49 @@ On Nuxt or Inertia the same button carries a platform-native navigation:
1163
1354
  ```
1164
1355
 
1165
1356
  Because a modal dialog is client-side UI, SSR (Nuxt) renders it closed and
1166
- empty; content appears on first open (or immediately with `eager`).
1357
+ empty; content appears when it opens (or immediately with `eager`).
1358
+
1359
+ ### In the URL: `navigation`
1360
+
1361
+ `navigation` puts the open state in the query, like BbTabs: opening writes
1362
+ `?dialog=true`, closing removes it, a link or reload carrying the key opens
1363
+ the dialog, and **Back closes it**. Back is the system's close request, so it
1364
+ settles `reason: 'escape'`, and `persistent`, `prevent-close` or a saving
1365
+ form refuse it like Escape (the key comes back, with the deny nudge).
1366
+
1367
+ ```vue
1368
+ <BbDialog id="filters" navigation title="Filters">…</BbDialog>
1369
+ ```
1370
+
1371
+ - `query-key` names the parameter (default `'dialog'`; BbOffCanvas:
1372
+ `'offcanvas'`). Two URL dialogs on one page each need their own.
1373
+ - `replace` writes without a history entry: Back then leaves the page.
1374
+ - A value opens unless it is `'false'` (`?dialog`, `?dialog=1` both open).
1375
+ - It carries the open state only. A record id is your own query (here
1376
+ vue-router's; on Inertia, your page prop):
1377
+
1378
+ ```vue
1379
+ <script setup lang="ts">
1380
+ import { BbDialog } from 'bitboss-ui';
1381
+ import { useRoute } from 'vue-router';
1382
+
1383
+ const route = useRoute();
1384
+ </script>
1385
+
1386
+ <template>
1387
+ <!-- /invoices?invoice=12&dialog=true opens on invoice 12 -->
1388
+ <BbDialog id="invoice" navigation title="Invoice">
1389
+ <p>Invoice {{ route.query.invoice }}</p>
1390
+ </BbDialog>
1391
+ </template>
1392
+ ```
1393
+
1394
+ Template dialogs only: a dialog opened on demand has no place in the page URL
1395
+ (`navigation` is ignored there, with a dev warning). On the phone the adaptive
1396
+ sheet is the same dialog: one key, and Back is refused by the sheet's own
1397
+ gates. Under Inertia, a Back that changes only dialog keys never reaches
1398
+ Inertia, so the page is not remounted; this needs `bitbossUiPlugin` installed
1399
+ (the standard setup), which registers its history listener first.
1167
1400
 
1168
1401
  ### Adaptive: a bottom sheet on mobile
1169
1402
 
@@ -1191,6 +1424,10 @@ so a mid-flow resize never swaps modal ↔ sheet.
1191
1424
  `{ pt: { header: 'pt-10' } }` does the same; both merge after what the
1192
1425
  dialog forwards, so nothing of yours is lost (passthrough guide § Inside
1193
1426
  the sheet).
1427
+ - `snap-points` and `v-model:snap` give the phone sheet several resting
1428
+ heights (`:snap-points="[0.5, 1]"`: opens at half the screen, drags up to
1429
+ full). The desktop dialog ignores them. Everything about the stops (drag,
1430
+ scroll, the slider handle) is in the BbOffCanvas guide, § Snap points.
1194
1431
  - `:adaptive="false"` keeps the centered dialog everywhere; a bare `adaptive`
1195
1432
  forces the sheet behavior on regardless of the global config.
1196
1433
 
@@ -1244,35 +1481,37 @@ a row at the end), a muted ✕ on `pt:close`, a primary ring with offset while
1244
1481
  <BbDialog
1245
1482
  id="record-payment"
1246
1483
  :adaptive="false"
1484
+ :default-data="NEW_PAYMENT"
1247
1485
  pt:close="text-(--bb-text-muted)"
1248
1486
  pt:footer="bg-(--bb-hover)"
1249
1487
  pt:header="bg-(--bb-muted)"
1250
1488
  pt:root:open="ring-2 ring-offset-2 ring-(--bb-primary)"
1251
1489
  title="Record payment — INV-2048"
1252
- @show="seed"
1253
1490
  >
1254
- <div class="grid gap-3">
1255
- <BbNumberInput
1256
- v-model="draft.amount"
1257
- label="Amount"
1258
- label-mode="outside"
1259
- :max-precision="2"
1260
- :min="0"
1261
- >
1262
- <template #prefix>€</template>
1263
- </BbNumberInput>
1264
- <BbDatePickerInput
1265
- v-model="draft.paidAt"
1266
- label="Paid on"
1267
- label-mode="outside"
1268
- />
1269
- <BbSelect
1270
- v-model="draft.method"
1271
- :items="['bank transfer', 'card', 'check']"
1272
- label="Method"
1273
- label-mode="outside"
1274
- />
1275
- </div>
1491
+ <template #default="{ data }">
1492
+ <div class="grid gap-3">
1493
+ <BbNumberInput
1494
+ v-model="data.amount"
1495
+ label="Amount"
1496
+ label-mode="outside"
1497
+ :max-precision="2"
1498
+ :min="0"
1499
+ >
1500
+ <template #prefix>€</template>
1501
+ </BbNumberInput>
1502
+ <BbDatePickerInput
1503
+ v-model="data.paidAt"
1504
+ label="Paid on"
1505
+ label-mode="outside"
1506
+ />
1507
+ <BbSelect
1508
+ v-model="data.method"
1509
+ :items="['bank transfer', 'card', 'check']"
1510
+ label="Method"
1511
+ label-mode="outside"
1512
+ />
1513
+ </div>
1514
+ </template>
1276
1515
  <template #footer="{ close }">
1277
1516
  <BbButton variant="ghost" @click="close">Cancel</BbButton>
1278
1517
  <BbButton variant="secondary" @click="close"> Record payment </BbButton>
@@ -1281,7 +1520,6 @@ a row at the end), a muted ✕ on `pt:close`, a primary ring with offset while
1281
1520
  </template>
1282
1521
 
1283
1522
  <script setup lang="ts">
1284
- import { reactive } from 'vue';
1285
1523
  import {
1286
1524
  BbButton,
1287
1525
  BbDatePickerInput,
@@ -1301,17 +1539,11 @@ type PaymentDraft = {
1301
1539
  // already lays its actions out in a row at the end), muted ✕ on `pt:close`, a
1302
1540
  // primary ring with offset while `pt:root:open`. There is no `panel` — the
1303
1541
  // `<dialog>` is the surface. Backdrop is `::backdrop`, CSS not pt.
1304
- const draft = reactive<PaymentDraft>({
1305
- amount: null,
1306
- paidAt: null,
1307
- method: null,
1308
- });
1309
-
1310
- /** Re-seed on `@show` so a canceled draft never leaks into the next open. */
1311
- const seed = () => {
1312
- draft.amount = 1280;
1313
- draft.paidAt = new Date().toISOString();
1314
- draft.method = 'bank transfer';
1542
+ // Every opening edits a fresh copy, so a canceled draft never leaks.
1543
+ const NEW_PAYMENT: PaymentDraft = {
1544
+ amount: 1280,
1545
+ paidAt: new Date().toISOString(),
1546
+ method: 'bank transfer',
1315
1547
  };
1316
1548
  </script>
1317
1549
  ```
@@ -1350,8 +1582,9 @@ A `pt` value written in `<script>` (a constant, a preset, a wrapper's
1350
1582
  close — use `persistent` instead; the close paths already guard correctly.
1351
1583
  - **Don't leave the `header` slot without a close control** — it replaces the
1352
1584
  default ×, so you must render your own wired to `close`.
1353
- - **Don't query dialog content before it has opened** unless you set `eager` —
1354
- the body renders lazily.
1585
+ - **Don't query dialog content before it has opened, or after it has closed**,
1586
+ unless you set `eager`: the body exists only while the dialog is open or
1587
+ closing.
1355
1588
  - **Don't forget `viewport-fit=cover`** in the page's viewport meta when the
1356
1589
  dialog is `adaptive`: on a phone its sheet keeps its bands clear of the
1357
1590
  notch and home indicator with band padding (and lifts above the on-screen
@@ -1384,6 +1617,7 @@ A `pt` value written in `<script>` (a constant, a preset, a wrapper's
1384
1617
  ## v-model
1385
1618
 
1386
1619
  - `v-model` — type: `boolean \| undefined`
1620
+ - `v-model:snap` — type: `BbSnapPoint \| undefined`
1387
1621
 
1388
1622
  ## Props
1389
1623
 
@@ -1391,17 +1625,23 @@ A `pt` value written in `<script>` (a constant, a preset, a wrapper's
1391
1625
  | --- | --- | --- | --- | --- |
1392
1626
  | `pt:<part>`, `pt:<part>:<state>`, `pt` | `PtValue` | | | Passthrough — a class list, or `{ class, style, attrs }`, bound to one named part of the component; the state form applies only while that state is on. Parts: `close`, `content`, `description`, `footer`, `header`, `root`, `sheet`, `title`. States: `fullscreen`, `open`. What each one is: _Passthrough parts and states_ below. `pt` is the object form with the same keys minus the prefix (`{ icon: '…', 'icon:loading': '…' }`). See `guides/passthrough.md`. In `<script>` a `pt` value is typed `BbDialogProps['pt']` (`PtValue` is not exported): § Typing `pt` in `<script>`. |
1393
1627
  | `adaptive` | `boolean \| undefined` | ``config.adaptive` (`true`)` | | On mobile, renders as a `BbOffCanvas` drawer instead of a centred dialog; the surface is chosen when the dialog opens and kept until it closes. Unset: the global `config.adaptive`. |
1628
+ | `defaultData` | `Record<string, any> \| undefined` | | | The `data` of an opening that passes none (a create), as an object: copied at once and exposed as `data`, so inputs bind `data.x` with no guard. An opening with data lays it over this (`{ ...defaultData, ...row }`, one level). `reset()` restores a fresh copy, and every close does too. |
1394
1629
  | `description` | `string \| undefined` | | | One short line under the title, muted (the shadcn `DialogDescription` shape), and the only element `aria-describedby` points at. Keep it to a sentence; anything longer belongs in the body. |
1395
- | `eager` | `boolean \| undefined` | `false` | | Render dialog content immediately instead of waiting for the first open. |
1630
+ | `eager` | `boolean \| undefined` | `false` | | Keep the body mounted while closed, from before the first opening. Without it the body mounts per opening and unmounts after `@hidden`. `data` still clears while closed, so an eager body guards its reads. |
1396
1631
  | `focusTarget` | `string \| undefined` | | | CSS selector of the element to focus after opening. Defaults to the first tabbable element. |
1397
1632
  | `fullscreen` | `boolean \| "mobile" \| undefined` | | | Display the dialog fullscreen. Use `'mobile'` to restrict fullscreen to small devices. |
1398
1633
  | `hideClose` | `boolean \| undefined` | `false` | | Hide the default close (×) button. The button is shown by default; set this to remove it (e.g. for a `persistent` dialog you don't want dismissible — the × otherwise still closes a persistent dialog). |
1399
1634
  | `id` | `string \| undefined` | | | Names the dialog for `v-bb-dialog="'edit'"` and `useBbDialogContext`, found nearest to the trigger, so two pages may reuse an id. Also the DOM `id`: one dialog per id, rows pass `data`. |
1400
1635
  | `modelValue` | `boolean \| undefined` | `false` | | Used by v-model to open / close the dialog. A dialog with no `v-model` starts closed. |
1636
+ | `navigation` | `boolean \| undefined` | `false` | | Syncs the open state with the URL: opening writes `?<queryKey>=true`, closing removes it, a URL carrying the key opens the dialog on load, and Back closes it (refused like Escape when locked). Template dialogs only; a record id stays in your own query. |
1401
1637
  | `offCanvasProps` | `Partial<BbOffCanvasProps> \| undefined` | | | Props for the phone sheet (the `BbOffCanvas` that `adaptive` renders on mobile), spread over its defaults. A `pt` inside it styles only the sheet (passthrough guide § Inside the sheet). |
1402
1638
  | `persistent` | `boolean \| undefined` | `false` | | Prevent closing via outside clicks or Escape key. The header close (×) button STILL closes a persistent dialog — that is the documented escape hatch; combine with `hideClose` to remove it, or use `preventClose` to block every dismissal. Programmatic close is still allowed. |
1403
1639
  | `preventClose` | `boolean \| undefined` | ``undefined` (follows a saving form inside)` | | Refuses every user close (backdrop, Escape and the ✕, and the slots' `close`) with the deny nudge; `v-model` still closes. Unset: locked while a `BbForm` inside saves (an async `@submit`). `true`: always. `false`: never, not even mid-save. |
1640
+ | `queryKey` | `string \| undefined` | `"dialog"` | | The query key `navigation` writes. |
1641
+ | `replace` | `boolean \| undefined` | `false` | | With `navigation`, replaces the history entry instead of pushing one, so Back leaves the page instead of closing the dialog. |
1404
1642
  | `size` | `Responsive<NonNullable<number \| CssSize \| keyof BbDialogSizes \| undefined>> \| undefined` | `"sm"` | | Controls the maximum width of the dialog. Accepts named presets from `'xs'` to `'2xl'`, a custom CSS width string, or a numeric pixel value — or a per-breakpoint map (e.g. `{ default: 'sm', lg: 'lg' }`) to switch width responsively. |
1643
+ | `snap` | `BbSnapPoint \| undefined` | | | The phone sheet's snap stop (Q71.8), one of the `snapPoints` entries. The desktop dialog ignores it. |
1644
+ | `snapPoints` | `BbSnapPoint[] \| undefined` | | | Heights the phone sheet rests at, smallest first: a fraction of the screen (`0.5`; `1` = as tall as it gets) or a pixel height (`'240px'`). The first stop is the opening height; `v-model:snap` reads and sets the stop. Phone sheet only: the desktop dialog ignores it. |
1405
1645
  | `stack` | `string \| undefined` | | | Name of the stack this dialog belongs to. Dialogs sharing the same `stack` value replace one another: only the most recently opened stays visible while the others in the stack are hidden beneath it. Dialogs in different stacks — or with no `stack` — never affect each other and remain fully visible. |
1406
1646
  | `title` | `string \| undefined` | | | Title text announced to assistive technologies. If omitted, no header is rendered (unless a `header` slot is supplied) and accessibility must be handled manually. |
1407
1647
  | `transitionDuration` | `number \| undefined` | `250` | | Controls the duration of the open and close animations. |
@@ -1435,14 +1675,15 @@ States are listed in precedence order: when two are on at once and their entries
1435
1675
  - `show` — `(): void` — Emitted as soon as the dialog starts to open (model becomes true).
1436
1676
  - `shown` — `(): void` — Emitted once the open transition has run (symmetric with `hidden`): the dialog is settled, so code that measures or anchors to its content sees the final layout. Not emitted for a dialog closed before it finished opening.
1437
1677
  - `update:modelValue` — `(value: boolean): void` — Emitted with the new value whenever `v-model` changes.
1678
+ - `update:snap` — `(value: BbSnapPoint \| undefined): void` — Emitted with the new value whenever `v-model:snap` changes.
1438
1679
 
1439
1680
  ## Slots
1440
1681
 
1441
- - `default` — scope: `BbDialogDefaultSlotProps` `{ close: () => void; complete: (value?: any) => void; data: any }` — Primary body content of the dialog, rendered in the scrollable body area.
1442
- - `description` — scope: `BbDialogDescriptionSlotProps` `{ text: string }` — Replaces the description text under the title. Renders the header even when `title` and `description` are unset — a slotted description is a description, the same rule `#title` follows.
1443
- - `footer` — scope: `BbDialogFooterSlotProps` `{ close: () => void; complete: (value?: any) => void; data: any }` — Content rendered in the dialog footer, below the body area. Typically used for action buttons.
1444
- - `header` — scope: `BbDialogHeaderSlotProps` `{ titleId: string; close: () => void; complete: (value?: any) => void; title: string; data: any }` — Replaces the whole header (title and ✕) on both surfaces, so it owns closing: wire your control to `close`, and `titleId` onto your title. It renders even without `title`. Its root is a flex item: give it `flex-1` to fill the width.
1445
- - `title` — scope: `BbDialogTitleSlotProps` `{ text: string }` — Replaces the default title text inside the header. Remains linked to the dialog through `aria-labelledby`. Providing this slot renders the header even when `title` is unset — a slotted title is a title.
1682
+ - `default` — scope: `BbDialogDefaultSlotProps` `{ close: () => void; complete: (value?: any) => void; reset: () => void; data: any }` — Primary body content of the dialog, rendered in the scrollable body area.
1683
+ - `description` — scope: `BbDialogDescriptionSlotProps` `{ text: string; data: any }` — Replaces the description text under the title. Renders the header even when `title` and `description` are unset — a slotted description is a description, the same rule `#title` follows.
1684
+ - `footer` — scope: `BbDialogFooterSlotProps` `{ close: () => void; complete: (value?: any) => void; reset: () => void; data: any }` — Content rendered in the dialog footer, below the body area. Typically used for action buttons.
1685
+ - `header` — scope: `BbDialogHeaderSlotProps` `{ titleId: string; close: () => void; complete: (value?: any) => void; reset: () => void; title: string; data: any }` — Replaces the whole header (title and ✕) on both surfaces, so it owns closing: wire your control to `close`, and `titleId` onto your title. It renders even without `title`. Its root is a flex item: give it `flex-1` to fill the width.
1686
+ - `title` — scope: `BbDialogTitleSlotProps` `{ text: string; data: any }` — Replaces the default title text inside the header. Remains linked to the dialog through `aria-labelledby`. Providing this slot renders the header even when `title` is unset — a slotted title is a title.
1446
1687
 
1447
1688
  ## CSS custom properties
1448
1689
 
@@ -1470,7 +1711,7 @@ Set these on the element, or on a class you put on it, to retune this component
1470
1711
 
1471
1712
  - `BbDialog` _(this component)_ — its own template binds: `header` → `div.bb-dialog__header`, `title` → `span.bb-dialog__title`, `description` → `p.bb-dialog__description`, `close` → `CloseButton`, `content` → `div.bb-dialog__content`, `footer` → `div.bb-dialog__footer`; hands `BbOffCanvas` the pt map `{ root: 'root', header: 'header', title: 'title', description: 'description', content: 'content', footer: 'footer', close: 'close', sheet: 'root', }` (ours → theirs)
1472
1713
  - `CloseButton` _(internal — not importable, reach it through `BbDialog`)_ — documented CSS variables: `--size`, `--p`
1473
- - `BbOffCanvas` _(public — [contract](./BbOffCanvas.md))_ — its own pt parts (`BbOffCanvas`): `header` → `div.bb-offcanvas__header`, `title` → `span.bb-offcanvas__title`, `description` → `p.bb-offcanvas__description`, `close` → `CloseButton`, `content` → `div.bb-offcanvas__content`, `footer` → `div.bb-offcanvas__footer`; documented CSS variables: `--stack-x`, `--stack-fill`, `--stack-transition-duration`, `--px`, `--handle-w`, `--handle-h`, `--handle-bg`, `--safe-top`, `--close-size`; also mounts `CloseButton`
1714
+ - `BbOffCanvas` _(public — [contract](./BbOffCanvas.md))_ — its own pt parts (`BbOffCanvas`): `header` → `div.bb-offcanvas__header`, `title` → `span.bb-offcanvas__title`, `description` → `p.bb-offcanvas__description`, `close` → `CloseButton`, `content` → `div.bb-offcanvas__content`, `footer` → `div.bb-offcanvas__footer`; documented CSS variables: `--stack-x`, `--snap-y`, `--stack-fill`, `--stack-transition-duration`, `--px`, `--handle-w`, `--handle-h`, `--handle-bg`, `--safe-top`, `--close-size`; also mounts `CloseButton`
1474
1715
 
1475
1716
  ## See Also
1476
1717