@morscherlab/mint-sdk 1.1.5 → 1.1.7

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 (100) hide show
  1. package/dist/{EmptyState-LMPS3keb.js → EmptyState-p90quq8Z.js} +2 -2
  2. package/dist/{EmptyState-LMPS3keb.js.map → EmptyState-p90quq8Z.js.map} +1 -1
  3. package/dist/{ExperimentPopover-D6_RGzTY.js → ExperimentPopover-tLwJ7XF4.js} +3 -3
  4. package/dist/{ExperimentPopover-D6_RGzTY.js.map → ExperimentPopover-tLwJ7XF4.js.map} +1 -1
  5. package/dist/{ExperimentSelectorModal-DcQ38zua.js → ExperimentSelectorModal-B6P2msT4.js} +12 -5
  6. package/dist/ExperimentSelectorModal-B6P2msT4.js.map +1 -0
  7. package/dist/{SettingsModal-Ccq2z-Ms.js → SettingsModal-D7LFUokT.js} +100 -156
  8. package/dist/SettingsModal-D7LFUokT.js.map +1 -0
  9. package/dist/__tests__/components/ActionMenu.test.d.ts +1 -0
  10. package/dist/__tests__/components/BaseModal.test.d.ts +1 -0
  11. package/dist/components/ActionMenu.vue.d.ts +21 -0
  12. package/dist/components/BaseModal.vue.d.ts +17 -1
  13. package/dist/components/ExperimentTimeline.vue.d.ts +1 -1
  14. package/dist/components/GeneratedPluginView.vue.d.ts +3 -3
  15. package/dist/components/GeneratedResultRenderer.vue.d.ts +3 -3
  16. package/dist/components/RackEditor.vue.d.ts +1 -1
  17. package/dist/components/SampleLegend.vue.d.ts +1 -1
  18. package/dist/components/SmartGroupManual.vue.d.ts +1 -1
  19. package/dist/components/index.d.ts +1 -0
  20. package/dist/components/index.js +8 -8
  21. package/dist/{components-CV5zuWa8.js → components-CDomhGSj.js} +477 -71
  22. package/dist/components-CDomhGSj.js.map +1 -0
  23. package/dist/composables/controlSchemaDoseDesign.d.ts +8 -1
  24. package/dist/composables/controlSchemaModel.d.ts +10 -1
  25. package/dist/composables/index.js +8 -8
  26. package/dist/composables/useAnalysisArtifacts.d.ts +9 -1
  27. package/dist/composables/useAppExperiment.d.ts +7 -0
  28. package/dist/composables/useBioTemplatePackWorkspace.d.ts +9 -1
  29. package/dist/composables/useControlWorkspace.d.ts +9 -1
  30. package/dist/composables/useGeneratedAnalysis.d.ts +9 -0
  31. package/dist/{composables-CW8NRXLB.js → composables-CRR_JxQD.js} +4 -4
  32. package/dist/{composables-CW8NRXLB.js.map → composables-CRR_JxQD.js.map} +1 -1
  33. package/dist/{experiment-utils-7Yw-h0tD.js → experiment-utils-CLnzPPq5.js} +170 -13
  34. package/dist/experiment-utils-CLnzPPq5.js.map +1 -0
  35. package/dist/index.js +13 -13
  36. package/dist/install.js +7 -7
  37. package/dist/styles.css +764 -610
  38. package/dist/templates/index.js +4 -4
  39. package/dist/{templates-CJRFiWUX.js → templates-Cr-48-u2.js} +2 -2
  40. package/dist/{templates-CJRFiWUX.js.map → templates-Cr-48-u2.js.map} +1 -1
  41. package/dist/types/components.d.ts +32 -0
  42. package/dist/types/index.d.ts +1 -1
  43. package/dist/{useControlSchema-BEAgLyyx.js → useControlSchema-BMVb2XIQ.js} +58 -10
  44. package/dist/useControlSchema-BMVb2XIQ.js.map +1 -0
  45. package/dist/{useDropdownState-Be-ug_ci.js → useDropdownState-DSb3BC3N.js} +2 -2
  46. package/dist/{useDropdownState-Be-ug_ci.js.map → useDropdownState-DSb3BC3N.js.map} +1 -1
  47. package/dist/{useExperimentSelector-DFg_iEHI.js → useExperimentSelector-D0pjj9WH.js} +3 -3
  48. package/dist/{useExperimentSelector-DFg_iEHI.js.map → useExperimentSelector-D0pjj9WH.js.map} +1 -1
  49. package/dist/{useFormBuilder-CR9pBApW.js → useFormBuilder-DG3HOFGA.js} +2 -2
  50. package/dist/{useFormBuilder-CR9pBApW.js.map → useFormBuilder-DG3HOFGA.js.map} +1 -1
  51. package/dist/{useProtocolTemplates-DXeIHMwV.js → useProtocolTemplates-BMt0LlXf.js} +21 -6
  52. package/dist/useProtocolTemplates-BMt0LlXf.js.map +1 -0
  53. package/package.json +2 -1
  54. package/src/__tests__/components/ActionMenu.test.ts +120 -0
  55. package/src/__tests__/components/BaseModal.test.ts +133 -0
  56. package/src/__tests__/components/SettingsModal.test.ts +9 -7
  57. package/src/__tests__/docs/extractDocsComponents.test.ts +15 -1
  58. package/src/__tests__/docs/extractDocsParsing.test.ts +22 -0
  59. package/src/__tests__/docs/frontendDocsCatalog.test.ts +80 -0
  60. package/src/components/ActionMenu.story.vue +26 -0
  61. package/src/components/ActionMenu.vue +209 -0
  62. package/src/components/AppSidebar.vue +2 -13
  63. package/src/components/ArtifactSelectorModal.vue +8 -1
  64. package/src/components/BaseModal.story.vue +107 -1
  65. package/src/components/BaseModal.vue +152 -4
  66. package/src/components/BioTemplateExperimentWorkspaceView.vue +8 -1
  67. package/src/components/BioTemplatePackWorkspaceView.vue +8 -1
  68. package/src/components/BioTemplatePresetWorkspaceView.vue +8 -1
  69. package/src/components/BioTemplateRenderer.vue +8 -1
  70. package/src/components/ComponentBindingRenderer.vue +8 -1
  71. package/src/components/ControlWorkspaceView.vue +8 -1
  72. package/src/components/DoseDesignWorkspaceView.vue +8 -1
  73. package/src/components/ExperimentSelectorModal.vue +8 -1
  74. package/src/components/FormBuilder.vue +5 -0
  75. package/src/components/GeneratedPluginView.vue +12 -2
  76. package/src/components/GeneratedResultRenderer.vue +12 -2
  77. package/src/components/JobsStatusTray.vue +8 -1
  78. package/src/components/PluginWorkspaceView.vue +10 -1
  79. package/src/components/SettingsModal.vue +102 -149
  80. package/src/components/index.ts +1 -0
  81. package/src/composables/controlSchemaDoseDesign.ts +8 -1
  82. package/src/composables/controlSchemaModel.ts +10 -1
  83. package/src/composables/useAnalysisArtifacts.ts +9 -1
  84. package/src/composables/useAppExperiment.ts +7 -0
  85. package/src/composables/useBioTemplatePackWorkspace.ts +9 -1
  86. package/src/composables/useControlWorkspace.ts +9 -1
  87. package/src/composables/useGeneratedAnalysis.ts +9 -0
  88. package/src/styles/components/action-menu.css +59 -0
  89. package/src/styles/components/icon-button.css +7 -3
  90. package/src/styles/components/modal.css +293 -0
  91. package/src/styles/components/settings-modal.css +3 -273
  92. package/src/styles/index.css +1 -0
  93. package/src/types/components.ts +38 -0
  94. package/src/types/index.ts +3 -0
  95. package/dist/ExperimentSelectorModal-DcQ38zua.js.map +0 -1
  96. package/dist/SettingsModal-Ccq2z-Ms.js.map +0 -1
  97. package/dist/components-CV5zuWa8.js.map +0 -1
  98. package/dist/experiment-utils-7Yw-h0tD.js.map +0 -1
  99. package/dist/useControlSchema-BEAgLyyx.js.map +0 -1
  100. package/dist/useProtocolTemplates-DXeIHMwV.js.map +0 -1
@@ -0,0 +1,209 @@
1
+ <script setup lang="ts">
2
+ /** Compact overflow actions with an IconButton trigger and a viewport-safe teleported menu. */
3
+ import { computePosition, flip, offset, shift } from '@floating-ui/dom'
4
+ import { Ellipsis } from 'lucide-vue-next'
5
+ import { nextTick, onBeforeUnmount, ref } from 'vue'
6
+ import type { ActionMenuItem } from '../types/components'
7
+ import IconButton from './IconButton.vue'
8
+
9
+ interface Props {
10
+ label: string
11
+ items: ActionMenuItem[]
12
+ disabled?: boolean
13
+ }
14
+
15
+ const props = withDefaults(defineProps<Props>(), {
16
+ disabled: false,
17
+ })
18
+
19
+ const emit = defineEmits<{
20
+ select: [item: ActionMenuItem]
21
+ open: []
22
+ close: []
23
+ }>()
24
+
25
+ const isOpen = ref(false)
26
+ const triggerRootRef = ref<HTMLElement | null>(null)
27
+ const panelRef = ref<HTMLElement | null>(null)
28
+ const panelStyle = ref<Record<string, string>>({ visibility: 'hidden' })
29
+
30
+ function triggerElement(): HTMLButtonElement | null {
31
+ return triggerRootRef.value?.querySelector('button') ?? null
32
+ }
33
+
34
+ function menuItems(): HTMLElement[] {
35
+ if (!panelRef.value) return []
36
+ return Array.from(
37
+ panelRef.value.querySelectorAll<HTMLElement>('.mint-action-menu__item:not(:disabled)'),
38
+ )
39
+ }
40
+
41
+ async function positionPanel() {
42
+ const trigger = triggerElement()
43
+ const panel = panelRef.value
44
+ if (!trigger || !panel) return
45
+
46
+ const position = await computePosition(trigger, panel, {
47
+ placement: 'bottom-end',
48
+ strategy: 'fixed',
49
+ middleware: [
50
+ offset(4),
51
+ flip(),
52
+ shift({ padding: 8 }),
53
+ ],
54
+ })
55
+ panelStyle.value = {
56
+ left: `${position.x}px`,
57
+ top: `${position.y}px`,
58
+ visibility: 'visible',
59
+ }
60
+ }
61
+
62
+ function addGlobalListeners() {
63
+ document.addEventListener('pointerdown', handleDocumentPointerDown, true)
64
+ window.addEventListener('resize', handleViewportChange)
65
+ window.addEventListener('scroll', handleViewportChange, true)
66
+ }
67
+
68
+ function removeGlobalListeners() {
69
+ document.removeEventListener('pointerdown', handleDocumentPointerDown, true)
70
+ window.removeEventListener('resize', handleViewportChange)
71
+ window.removeEventListener('scroll', handleViewportChange, true)
72
+ }
73
+
74
+ async function openMenu() {
75
+ if (props.disabled || isOpen.value) return
76
+ panelStyle.value = { visibility: 'hidden' }
77
+ isOpen.value = true
78
+ emit('open')
79
+ addGlobalListeners()
80
+ await nextTick()
81
+ await positionPanel()
82
+ menuItems()[0]?.focus()
83
+ }
84
+
85
+ function closeMenu(returnFocus = false) {
86
+ if (!isOpen.value) return
87
+ isOpen.value = false
88
+ removeGlobalListeners()
89
+ emit('close')
90
+ if (returnFocus) {
91
+ nextTick(() => triggerElement()?.focus())
92
+ }
93
+ }
94
+
95
+ function toggleMenu() {
96
+ if (isOpen.value) closeMenu()
97
+ else void openMenu()
98
+ }
99
+
100
+ function handleSelect(item: ActionMenuItem) {
101
+ if (item.disabled) return
102
+ emit('select', item)
103
+ closeMenu()
104
+ }
105
+
106
+ function handleDocumentPointerDown(event: PointerEvent) {
107
+ const target = event.target
108
+ if (!(target instanceof Node)) return
109
+ if (triggerRootRef.value?.contains(target) || panelRef.value?.contains(target)) return
110
+ closeMenu()
111
+ }
112
+
113
+ function handleViewportChange() {
114
+ closeMenu()
115
+ }
116
+
117
+ function handleMenuKeydown(event: KeyboardEvent) {
118
+ const items = menuItems()
119
+ const currentIndex = items.indexOf(document.activeElement as HTMLElement)
120
+
121
+ if (event.key === 'Escape') {
122
+ event.preventDefault()
123
+ closeMenu(true)
124
+ return
125
+ }
126
+ if (event.key === 'Tab') {
127
+ closeMenu()
128
+ return
129
+ }
130
+ if (event.key === 'Home') {
131
+ event.preventDefault()
132
+ items[0]?.focus()
133
+ return
134
+ }
135
+ if (event.key === 'End') {
136
+ event.preventDefault()
137
+ items[items.length - 1]?.focus()
138
+ return
139
+ }
140
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
141
+
142
+ event.preventDefault()
143
+ const direction = event.key === 'ArrowDown' ? 1 : -1
144
+ const nextIndex = (Math.max(currentIndex, 0) + direction + items.length) % items.length
145
+ items[nextIndex]?.focus()
146
+ }
147
+
148
+ onBeforeUnmount(removeGlobalListeners)
149
+ </script>
150
+
151
+ <template>
152
+ <span ref="triggerRootRef" class="mint-action-menu">
153
+ <IconButton
154
+ variant="secondary"
155
+ size="sm"
156
+ :label="label"
157
+ :disabled="disabled"
158
+ aria-haspopup="menu"
159
+ :aria-expanded="isOpen"
160
+ @click="toggleMenu"
161
+ >
162
+ <Ellipsis
163
+ class="mint-action-menu__trigger-icon"
164
+ :size="16"
165
+ :stroke-width="2"
166
+ aria-hidden="true"
167
+ />
168
+ </IconButton>
169
+ </span>
170
+
171
+ <Teleport to="body">
172
+ <div
173
+ v-if="isOpen"
174
+ ref="panelRef"
175
+ class="mint-action-menu__panel"
176
+ :style="panelStyle"
177
+ role="menu"
178
+ @keydown="handleMenuKeydown"
179
+ >
180
+ <template v-for="item in items" :key="item.id">
181
+ <a
182
+ v-if="item.href !== undefined && !item.disabled"
183
+ :href="item.href"
184
+ :target="item.target"
185
+ :rel="item.target === '_blank' ? 'noopener noreferrer' : undefined"
186
+ :class="['mint-action-menu__item', { 'mint-action-menu__item--danger': item.danger }]"
187
+ role="menuitem"
188
+ @click="handleSelect(item)"
189
+ >
190
+ {{ item.label }}
191
+ </a>
192
+ <button
193
+ v-else
194
+ type="button"
195
+ :class="['mint-action-menu__item', { 'mint-action-menu__item--danger': item.danger }]"
196
+ role="menuitem"
197
+ :disabled="item.disabled"
198
+ @click="handleSelect(item)"
199
+ >
200
+ {{ item.label }}
201
+ </button>
202
+ </template>
203
+ </div>
204
+ </Teleport>
205
+ </template>
206
+
207
+ <style>
208
+ @import '../styles/components/action-menu.css';
209
+ </style>
@@ -9,20 +9,9 @@
9
9
  * When activeView is omitted, the first non-empty panel view is selected.
10
10
  * When no view has matching panels, the sidebar hides entirely.
11
11
  *
12
- * @example
12
+ * @example Recommended
13
13
  * ```vue
14
- * <AppSidebar :panels="toolPanels" :active-view="activeTab">
15
- * <template #section-parameters>
16
- * <BaseSlider v-model="threshold" label="Threshold" />
17
- * </template>
18
- * <template #section-display>
19
- * <BaseToggle v-model="showOutliers" label="Show outliers" />
20
- * </template>
21
- * <template #header>Plugin Tools</template>
22
- * </AppSidebar>
23
- *
24
- * <!-- Direct schema-driven controls -->
25
- * <AppSidebar :controls="controls" :active-view="activeTab" v-model="values" />
14
+ * <AppSidebar v-model="values" :model="sidebarModel" />
26
15
  * ```
27
16
  */
28
17
  import { computed, ref, useSlots, type Slots } from 'vue'
@@ -1,5 +1,12 @@
1
1
  <script setup lang="ts">
2
- /** Modal for browsing and selecting an analysis artifact of an experiment, with filters and keyboard nav. */
2
+ /**
3
+ * Modal for browsing and selecting an analysis artifact of an experiment, with filters and keyboard nav.
4
+ *
5
+ * @example Recommended
6
+ * ```vue
7
+ * <ArtifactSelectorModal v-model="open" :experiment-id="experimentId" @select="selectArtifact" />
8
+ * ```
9
+ */
3
10
  import { computed, ref, watch, nextTick } from 'vue'
4
11
  import type { ModalSize } from '../types'
5
12
  import type {
@@ -2,7 +2,7 @@
2
2
  import { ref } from 'vue'
3
3
  import BaseModal from './BaseModal.vue'
4
4
  import BaseButton from './BaseButton.vue'
5
- import type { ModalSize } from '../types'
5
+ import type { ModalSize, ModalTab } from '../types'
6
6
 
7
7
  const modalSizes: ModalSize[] = ['sm', 'md', 'lg', 'xl', 'full']
8
8
  const galleryOpen = ref<Record<string, boolean>>({
@@ -19,6 +19,28 @@ const refreshOpen = ref(false)
19
19
  const subtitleOpen = ref(false)
20
20
  const drawerOpen = ref(false)
21
21
  const sheetOpen = ref(false)
22
+
23
+ // ---- layout="tabs": shell-level tab strip (2–4 sections) ----
24
+ const tabsOpen = ref(false)
25
+ const tabsActive = ref('processing')
26
+ const stripTabs: ModalTab[] = [
27
+ { id: 'processing', label: 'Processing', meta: '6' },
28
+ { id: 'peaks', label: 'Peak detection', meta: '9' },
29
+ { id: 'correction', label: 'Correction', meta: '4' },
30
+ { id: 'appearance', label: 'Appearance' },
31
+ ]
32
+
33
+ // ---- layout="rail": shell-level rail (5+ sections) ----
34
+ const railOpen = ref(false)
35
+ const railActive = ref('security')
36
+ const railTabs: ModalTab[] = [
37
+ { id: 'profile', label: 'Profile', description: 'Name, display name, and email', icon: 'M20 21a8 8 0 0 0-16 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8' },
38
+ { id: 'password', label: 'Password', description: 'Change your sign-in password', icon: 'M6 11h12v10H6zM9 11V7a3 3 0 0 1 6 0v4' },
39
+ { id: 'security', label: 'Security', meta: '2', description: 'Passkeys and institutional sign-in', icon: 'm12 3 8 4.5v9L12 21l-8-4.5v-9L12 3Z' },
40
+ { id: 'notifications', label: 'Notifications', description: 'Email and in-app alerts', icon: 'M18 8a6 6 0 1 0-12 0c0 7-3 8-3 8h18s-3-1-3-8M13.7 21a2 2 0 0 1-3.4 0' },
41
+ { id: 'tokens', label: 'API tokens', meta: '3', description: 'Programmatic access keys', icon: 'M15 7h3a4 4 0 0 1 0 8h-3M9 15H6a4 4 0 0 1 0-8h3M8 11h8' },
42
+ { id: 'appearance', label: 'Appearance', description: 'Theme and density', icon: 'M12 3a9 9 0 1 0 0 18 3 3 0 0 0 0-6 3 3 0 0 1 0-6 3 3 0 0 0 0-6M7 10h.01M12 7h.01M17 10h.01' },
43
+ ]
22
44
  </script>
23
45
 
24
46
  <template>
@@ -225,5 +247,89 @@ const sheetOpen = ref(false)
225
247
  </template>
226
248
  </BaseModal>
227
249
  </Variant>
250
+
251
+ <Variant title="Tabs layout — shell-level strip">
252
+ <div style="padding: 2rem;">
253
+ <BaseButton variant="primary" @click="tabsOpen = true">
254
+ Open tabbed modal
255
+ </BaseButton>
256
+ <p style="margin: 1rem 0 0; font-size: 0.8125rem; color: var(--text-muted);">
257
+ The strip is a shell region between header and body: flush with the modal edges,
258
+ it never scrolls with the content, and the body keeps one fixed height across tabs.
259
+ Arrow keys move between tabs. For 2–4 sections; use the rail beyond that.
260
+ </p>
261
+ </div>
262
+
263
+ <BaseModal
264
+ v-model="tabsOpen"
265
+ v-model:active-tab="tabsActive"
266
+ layout="tabs"
267
+ :tabs="stripTabs"
268
+ size="lg"
269
+ title="Isotope Tracer Analysis — settings"
270
+ subtitle="Plugin 2.4 · applies to this workspace"
271
+ >
272
+ <div style="display: flex; flex-direction: column; gap: 0.875rem;">
273
+ <p style="margin: 0; font-size: 0.8125rem; color: var(--text-secondary);">
274
+ Active tab: <strong style="color: var(--text-primary);">{{ tabsActive }}</strong> —
275
+ pane content swaps here while the card height stays put.
276
+ </p>
277
+ <div v-for="n in 4" :key="n" style="display: flex; align-items: center; gap: 0.875rem; padding: 0.6875rem 0; border-bottom: 1px solid var(--border-light);">
278
+ <span style="width: 9.5rem; flex-shrink: 0; font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-muted);">Setting {{ n }}</span>
279
+ <span style="flex: 1; padding: 0.375rem 0.625rem; border: 1px solid var(--border-color); border-radius: 0.4375rem; background: var(--bg-secondary); font-family: var(--font-mono); font-size: 0.8125rem;">value {{ n }}</span>
280
+ </div>
281
+ </div>
282
+ <template #footer>
283
+ <BaseButton variant="secondary" @click="tabsOpen = false">Cancel</BaseButton>
284
+ <BaseButton variant="primary" @click="tabsOpen = false">Save settings</BaseButton>
285
+ </template>
286
+ </BaseModal>
287
+ </Variant>
288
+
289
+ <Variant title="Rail layout — shell-level sidebar">
290
+ <div style="padding: 2rem;">
291
+ <BaseButton variant="primary" @click="railOpen = true">
292
+ Open rail modal
293
+ </BaseButton>
294
+ <p style="margin: 1rem 0 0; font-size: 0.8125rem; color: var(--text-muted);">
295
+ For 5+ sections, or sections needing descriptions/counts. The rail is a real shell
296
+ column (no negative-margin bleed), scrolls independently of the pane, and the pane
297
+ names the active section in its own header. Collapses to the strip under 640px.
298
+ </p>
299
+ </div>
300
+
301
+ <BaseModal
302
+ v-model="railOpen"
303
+ v-model:active-tab="railActive"
304
+ layout="rail"
305
+ :tabs="railTabs"
306
+ size="lg"
307
+ title="Your account"
308
+ subtitle="R. Keller · rkeller · Admin"
309
+ close-on-overlay
310
+ >
311
+ <template #rail-footer>
312
+ <button
313
+ type="button"
314
+ style="display: flex; align-items: center; gap: 0.5rem; margin-top: auto; padding: 0.4375rem 0.625rem; border: none; border-radius: 0.4375rem; background: transparent; color: var(--mint-error); font-family: inherit; font-size: 0.78125rem; font-weight: 500; text-align: left; cursor: pointer;"
315
+ @click="railOpen = false"
316
+ >
317
+ Log out
318
+ </button>
319
+ </template>
320
+
321
+ <div style="display: flex; flex-direction: column;">
322
+ <div v-for="n in 3" :key="n" style="display: flex; align-items: center; gap: 0.6875rem; padding: 0.5625rem 0; border-bottom: 1px solid var(--border-light);">
323
+ <span style="flex: 1; font-size: 0.8125rem; color: var(--text-primary);">{{ railActive }} row {{ n }}</span>
324
+ <span style="font-size: 0.71875rem; color: var(--text-muted);">detail</span>
325
+ </div>
326
+ </div>
327
+ <template #footer>
328
+ <span style="font-size: 0.75rem; color: var(--text-muted);">Changes apply immediately</span>
329
+ <div style="flex: 1;" />
330
+ <BaseButton variant="secondary" @click="railOpen = false">Close</BaseButton>
331
+ </template>
332
+ </BaseModal>
333
+ </Variant>
228
334
  </Story>
229
335
  </template>
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
- /** Modal dialog with backdrop, focus trap, Escape-to-close, and configurable size/variant. */
3
- import { ref, watch, nextTick, onUnmounted } from 'vue'
4
- import type { ModalSize, ModalVariant } from '../types'
2
+ /** Modal dialog with backdrop, focus trap, Escape-to-close, and configurable size/variant/layout. */
3
+ import { computed, ref, watch, nextTick, onUnmounted, type ComponentPublicInstance } from 'vue'
4
+ import type { ModalLayout, ModalSize, ModalTab, ModalVariant } from '../types'
5
5
  import { useEventListener } from '../composables/useEventListener'
6
6
 
7
7
  interface Props {
@@ -13,6 +13,16 @@ interface Props {
13
13
  closable?: boolean
14
14
  closeOnOverlay?: boolean
15
15
  closeOnEscape?: boolean
16
+ /**
17
+ * Shell layout. 'tabs' renders a tab strip between header and body;
18
+ * 'rail' renders a sidebar rail + pane as the shell's middle row.
19
+ * Both are shell regions — never part of the scrolling body content.
20
+ */
21
+ layout?: ModalLayout
22
+ /** Sections for the tab strip / rail. The strip renders only with 2+ tabs. */
23
+ tabs?: ModalTab[]
24
+ /** Id of the selected tab. Two-way bindable via update:activeTab. */
25
+ activeTab?: string
16
26
  }
17
27
 
18
28
  const props = withDefaults(defineProps<Props>(), {
@@ -21,13 +31,49 @@ const props = withDefaults(defineProps<Props>(), {
21
31
  closable: true,
22
32
  closeOnOverlay: false,
23
33
  closeOnEscape: true,
34
+ layout: 'plain',
35
+ tabs: () => [],
24
36
  })
25
37
 
26
38
  const emit = defineEmits<{
27
39
  'update:modelValue': [value: boolean]
40
+ 'update:activeTab': [id: string]
28
41
  close: []
29
42
  }>()
30
43
 
44
+ const activeTabMeta = computed(() => props.tabs.find((tab) => tab.id === props.activeTab))
45
+
46
+ const tabButtonRefs = new Map<string, HTMLElement>()
47
+
48
+ function setTabButtonRef(id: string, el: Element | ComponentPublicInstance | null) {
49
+ if (el instanceof HTMLElement) {
50
+ tabButtonRefs.set(id, el)
51
+ } else {
52
+ tabButtonRefs.delete(id)
53
+ }
54
+ }
55
+
56
+ function selectTab(tab: ModalTab) {
57
+ if (tab.disabled || tab.id === props.activeTab) return
58
+ emit('update:activeTab', tab.id)
59
+ }
60
+
61
+ /** ARIA tablist keyboard contract: arrows move selection to the next non-disabled tab, wrapping. */
62
+ function focusTab(delta: number) {
63
+ const enabled = props.tabs.filter((tab) => !tab.disabled)
64
+ if (enabled.length === 0) return
65
+ const currentIndex = enabled.findIndex((tab) => tab.id === props.activeTab)
66
+ const next = enabled[(currentIndex + delta + enabled.length) % enabled.length]
67
+ if (!next || next.id === props.activeTab) return
68
+ emit('update:activeTab', next.id)
69
+ tabButtonRefs.get(next.id)?.focus()
70
+ }
71
+
72
+ function isPathIcon(icon: ModalTab['icon']): icon is string | string[] {
73
+ if (!icon) return false
74
+ return Array.isArray(icon) || icon.trim().startsWith('M') || icon.trim().startsWith('m')
75
+ }
76
+
31
77
  const containerRef = ref<HTMLElement | null>(null)
32
78
  let previouslyFocused: HTMLElement | null = null
33
79
 
@@ -43,6 +89,8 @@ const FOCUSABLE_SELECTOR = [
43
89
  function getFocusableElements(): HTMLElement[] {
44
90
  if (!containerRef.value) return []
45
91
  return Array.from(containerRef.value.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
92
+ // Roving tabindex (inactive tabs/rail items) stays out of the Tab cycle.
93
+ .filter((el) => el.tabIndex !== -1)
46
94
  }
47
95
 
48
96
  function handleKeydown(event: KeyboardEvent) {
@@ -127,6 +175,7 @@ onUnmounted(() => {
127
175
  'mint-modal__container',
128
176
  `mint-modal__container--${size}`,
129
177
  `mint-modal__container--${variant}`,
178
+ `mint-modal__container--${layout}`,
130
179
  ]"
131
180
  role="dialog"
132
181
  aria-modal="true"
@@ -160,8 +209,107 @@ onUnmounted(() => {
160
209
  </button>
161
210
  </div>
162
211
 
212
+ <!-- Tab strip — a shell region between header and body, flush with the modal edges -->
213
+ <div
214
+ v-if="layout === 'tabs' && tabs.length > 1"
215
+ class="mint-modal__tabs"
216
+ role="tablist"
217
+ >
218
+ <button
219
+ v-for="tab in tabs"
220
+ :key="tab.id"
221
+ :ref="(el) => setTabButtonRef(tab.id, el)"
222
+ type="button"
223
+ role="tab"
224
+ :aria-selected="activeTab === tab.id"
225
+ :aria-disabled="tab.disabled || undefined"
226
+ :tabindex="activeTab === tab.id ? 0 : -1"
227
+ :class="['mint-modal__tab', {
228
+ 'mint-modal__tab--active': activeTab === tab.id,
229
+ 'mint-modal__tab--disabled': tab.disabled,
230
+ }]"
231
+ @click="selectTab(tab)"
232
+ @keydown.left.prevent="focusTab(-1)"
233
+ @keydown.right.prevent="focusTab(1)"
234
+ >
235
+ {{ tab.label }}
236
+ <span v-if="tab.meta" class="mint-modal__tab-meta">{{ tab.meta }}</span>
237
+ </button>
238
+ </div>
239
+
240
+ <!-- Rail layout: rail column + pane are the shell's middle row -->
241
+ <div v-if="layout === 'rail'" class="mint-modal__split">
242
+ <div class="mint-modal__rail" role="tablist" aria-orientation="vertical">
243
+ <div class="mint-modal__rail-items">
244
+ <button
245
+ v-for="tab in tabs"
246
+ :key="tab.id"
247
+ :ref="(el) => setTabButtonRef(tab.id, el)"
248
+ type="button"
249
+ role="tab"
250
+ :aria-selected="activeTab === tab.id"
251
+ :aria-disabled="tab.disabled || undefined"
252
+ :tabindex="activeTab === tab.id ? 0 : -1"
253
+ :class="['mint-modal__rail-item', {
254
+ 'mint-modal__rail-item--active': activeTab === tab.id,
255
+ 'mint-modal__rail-item--disabled': tab.disabled,
256
+ }]"
257
+ @click="selectTab(tab)"
258
+ @keydown.up.prevent="focusTab(-1)"
259
+ @keydown.down.prevent="focusTab(1)"
260
+ >
261
+ <span v-if="tab.icon" class="mint-modal__rail-item-icon" aria-hidden="true">
262
+ <svg
263
+ v-if="isPathIcon(tab.icon)"
264
+ viewBox="0 0 24 24"
265
+ fill="none"
266
+ stroke="currentColor"
267
+ stroke-width="1.8"
268
+ stroke-linecap="round"
269
+ stroke-linejoin="round"
270
+ >
271
+ <template v-if="Array.isArray(tab.icon)">
272
+ <path v-for="(d, index) in tab.icon" :key="index" :d="d" />
273
+ </template>
274
+ <path v-else :d="tab.icon" />
275
+ </svg>
276
+ <!-- Trusted, author-controlled markup only — same contract as SettingsTab.icon. -->
277
+ <span v-else v-html="tab.icon" />
278
+ </span>
279
+ <span class="mint-modal__rail-item-text">
280
+ <span class="mint-modal__rail-item-label">{{ tab.label }}</span>
281
+ <span v-if="tab.description" class="mint-modal__rail-item-description">{{ tab.description }}</span>
282
+ </span>
283
+ <span v-if="tab.meta" class="mint-modal__rail-item-meta">{{ tab.meta }}</span>
284
+ </button>
285
+ </div>
286
+ <!-- Quiet rail-bottom action (e.g. Log out) -->
287
+ <slot name="rail-footer" />
288
+ </div>
289
+
290
+ <section
291
+ class="mint-modal__pane"
292
+ role="tabpanel"
293
+ :aria-label="activeTabMeta?.label"
294
+ tabindex="0"
295
+ >
296
+ <header v-if="activeTabMeta?.label" class="mint-modal__pane-header">
297
+ <h4 class="mint-modal__pane-title">{{ activeTabMeta.label }}</h4>
298
+ <p v-if="activeTabMeta.description" class="mint-modal__pane-subtitle">
299
+ {{ activeTabMeta.description }}
300
+ </p>
301
+ </header>
302
+ <div class="mint-modal__pane-body">
303
+ <slot />
304
+ </div>
305
+ </section>
306
+ </div>
307
+
163
308
  <!-- Body -->
164
- <div class="mint-modal__body">
309
+ <div
310
+ v-else
311
+ :class="['mint-modal__body', layout === 'tabs' ? 'mint-modal__body--tabbed' : null]"
312
+ >
165
313
  <slot />
166
314
  </div>
167
315
 
@@ -1,5 +1,12 @@
1
1
  <script setup lang="ts">
2
- /** Current-experiment template workspace with load/save/reset controls and WellPlate/DataFrame/DoseCalculator previews. */
2
+ /**
3
+ * Current-experiment template workspace with load/save/reset controls and WellPlate/DataFrame/DoseCalculator previews.
4
+ *
5
+ * @example Recommended
6
+ * ```vue
7
+ * <BioTemplateExperimentWorkspaceView :target="collection" label="Assay" kind="collection" />
8
+ * ```
9
+ */
3
10
  import { computed } from 'vue'
4
11
  import {
5
12
  extractTemplateCollection,
@@ -1,5 +1,12 @@
1
1
  <script setup lang="ts">
2
- /** Complete current-experiment workspace for a curated biology template pack with save/load controls and component-prop slots. */
2
+ /**
3
+ * Complete current-experiment workspace for a curated biology template pack with save/load controls and component-prop slots.
4
+ *
5
+ * @example Recommended
6
+ * ```vue
7
+ * <BioTemplatePackWorkspaceView pack="cell-culture-screen" />
8
+ * ```
9
+ */
3
10
  import { computed, effectScope, onScopeDispose, shallowRef, unref, useSlots, watch, type EffectScope } from 'vue'
4
11
  import type { BioTemplateEnvelope, TemplateCollectionEnvelope, TemplatePackId } from '../templates'
5
12
  import type {
@@ -1,5 +1,12 @@
1
1
  <script setup lang="ts">
2
- /** Complete editable biology template preset workspace for WellPlate/DoseCalculator, LC-MS, qPCR, generated controls, preview components, and current-experiment save wiring. */
2
+ /**
3
+ * Complete editable biology template preset workspace for WellPlate/DoseCalculator, LC-MS, qPCR, generated controls, preview components, and current-experiment save wiring.
4
+ *
5
+ * @example Recommended
6
+ * ```vue
7
+ * <BioTemplatePresetWorkspaceView v-model="values" preset="wellplate-screen" />
8
+ * ```
9
+ */
3
10
  import { computed, effectScope, onScopeDispose, shallowRef, toRaw, unref, watch, type EffectScope } from 'vue'
4
11
  import type { BioTemplateControlValues, TemplatePresetId } from '../templates'
5
12
  import type {
@@ -1,5 +1,12 @@
1
1
  <script setup lang="ts">
2
- /** Render biology template envelopes with matching SDK components such as WellPlate, DoseCalculator, and DataFrame. */
2
+ /**
3
+ * Render biology template envelopes with matching SDK components such as WellPlate, DoseCalculator, and DataFrame.
4
+ *
5
+ * @example Recommended
6
+ * ```vue
7
+ * <BioTemplateRenderer :target="collection" />
8
+ * ```
9
+ */
3
10
  import { computed } from 'vue'
4
11
  import {
5
12
  toBioTemplateComponentBindings,
@@ -1,5 +1,12 @@
1
1
  <script setup lang="ts">
2
- /** Render generated SDK component bindings such as WellPlate, DoseCalculator, DataFrame, and PlateMapEditor. */
2
+ /**
3
+ * Render generated SDK component bindings such as WellPlate, DoseCalculator, DataFrame, and PlateMapEditor.
4
+ *
5
+ * @example Recommended
6
+ * ```vue
7
+ * <ComponentBindingRenderer :bindings="workspace.componentBindings.value" />
8
+ * ```
9
+ */
3
10
  import { computed, type Component } from 'vue'
4
11
  import DataFrame from './DataFrame.vue'
5
12
  import DoseCalculator from './DoseCalculator.vue'
@@ -1,5 +1,12 @@
1
1
  <script setup lang="ts">
2
- /** Complete control workspace component that turns a simple controls data model into AppTopBar, AppSidebar, and FormBuilder forms. */
2
+ /**
3
+ * Complete control workspace component that turns a simple controls data model into AppTopBar, AppSidebar, and FormBuilder forms.
4
+ *
5
+ * @example Recommended
6
+ * ```vue
7
+ * <ControlWorkspaceView v-model="values" :model="workspaceModel" title="Analysis" />
8
+ * ```
9
+ */
3
10
  import { computed, effectScope, onScopeDispose, shallowRef, toRaw, watch, type EffectScope } from 'vue'
4
11
  import type { TopBarVariant } from '../types'
5
12
  import type { FormEnhancements } from '../types/form-builder'
@@ -1,5 +1,12 @@
1
1
  <script setup lang="ts">
2
- /** Complete ControlWorkspaceView page shell for WellPlate + DoseCalculator dose design. */
2
+ /**
3
+ * Complete ControlWorkspaceView page shell for WellPlate + DoseCalculator dose design.
4
+ *
5
+ * @example Recommended
6
+ * ```vue
7
+ * <DoseDesignWorkspaceView v-model="values" title="Dose Design" />
8
+ * ```
9
+ */
3
10
  import { computed } from 'vue'
4
11
  import type { TopBarVariant } from '../types'
5
12
  import type { FormEnhancements } from '../types/form-builder'