@morscherlab/mint-sdk 1.2.0-beta.4 → 1.2.0-beta.5

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 (136) hide show
  1. package/dist/{EmptyState-p90quq8Z.js → EmptyState-NBqf2_8z.js} +2 -2
  2. package/dist/{EmptyState-p90quq8Z.js.map → EmptyState-NBqf2_8z.js.map} +1 -1
  3. package/dist/{ExperimentPopover-tLwJ7XF4.js → ExperimentPopover-CfOdh5Rm.js} +3 -3
  4. package/dist/{ExperimentPopover-tLwJ7XF4.js.map → ExperimentPopover-CfOdh5Rm.js.map} +1 -1
  5. package/dist/{ExperimentSelectorModal-B6P2msT4.js → ExperimentSelectorModal-DvIagrxY.js} +4 -4
  6. package/dist/{ExperimentSelectorModal-B6P2msT4.js.map → ExperimentSelectorModal-DvIagrxY.js.map} +1 -1
  7. package/dist/{SettingsModal-CkNM8qAI.js → SettingsModal-W3TYfnP9.js} +4 -7
  8. package/dist/SettingsModal-W3TYfnP9.js.map +1 -0
  9. package/dist/__tests__/components/PluginWorkspaceView.internal.test.d.ts +1 -0
  10. package/dist/__tests__/components/SecretInput.test.d.ts +1 -0
  11. package/dist/__tests__/components/WellEditPopupInternal.test.d.ts +1 -0
  12. package/dist/components/AppAvatarMenu.vue.d.ts +4 -0
  13. package/dist/components/AppSidebar.vue.d.ts +1 -1
  14. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
  15. package/dist/components/DropdownButton.vue.d.ts +2 -0
  16. package/dist/components/FitPanel.vue.d.ts +1 -1
  17. package/dist/components/PluginWorkspaceView.props.d.ts +29 -1
  18. package/dist/components/PluginWorkspaceView.vue.d.ts +5 -5
  19. package/dist/components/SecretInput.vue.d.ts +28 -0
  20. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +1 -1
  21. package/dist/components/index.d.ts +9 -0
  22. package/dist/components/index.js +8 -8
  23. package/dist/components/internal/WellEditPopupInternal.vue.d.ts +3 -1
  24. package/dist/components/internal/WorkspaceControlsInternal.vue.d.ts +23 -0
  25. package/dist/components/internal/WorkspaceShellInternal.vue.d.ts +70 -0
  26. package/dist/components/internal/WorkspaceSidebarInternal.vue.d.ts +43 -0
  27. package/dist/{components-MGNL6tRn.js → components-B80gV0P6.js} +575 -561
  28. package/dist/components-B80gV0P6.js.map +1 -0
  29. package/dist/composables/flattenWorkspaceBindings.d.ts +61 -0
  30. package/dist/composables/index.d.ts +5 -0
  31. package/dist/composables/index.js +9 -9
  32. package/dist/composables/pluginWorkspaceModel.d.ts +164 -0
  33. package/dist/composables/useAsync.d.ts +5 -1
  34. package/dist/composables/useBioTemplateWorkspace.d.ts +4 -20
  35. package/dist/composables/useCommandHistory.d.ts +5 -1
  36. package/dist/composables/useFocusTrap.d.ts +21 -0
  37. package/dist/composables/useMenuKeyboard.d.ts +29 -0
  38. package/dist/composables/useOptimisticMutation.d.ts +5 -1
  39. package/dist/composables/usePluginWorkspace.d.ts +51 -0
  40. package/dist/composables/useResourceCrud.d.ts +5 -1
  41. package/dist/composables/useRovingFocus.d.ts +24 -0
  42. package/dist/composables/useWellPainting.d.ts +5 -1
  43. package/dist/composables/useWellPlateAdapter.d.ts +5 -1
  44. package/dist/composables/useWellPlateValidation.d.ts +5 -1
  45. package/dist/composables/workspaceRecipes.d.ts +188 -0
  46. package/dist/{composables-CKxOcvgh.js → composables-C8mmD5ta.js} +65 -17
  47. package/dist/composables-C8mmD5ta.js.map +1 -0
  48. package/dist/{experiment-utils-CLnzPPq5.js → experiment-utils-DV3DrvE_.js} +70 -51
  49. package/dist/experiment-utils-DV3DrvE_.js.map +1 -0
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.js +13 -13
  52. package/dist/install.js +7 -7
  53. package/dist/styles.css +100 -49
  54. package/dist/templates/builderReadUtils.d.ts +1 -1
  55. package/dist/templates/index.js +4 -4
  56. package/dist/{templates-BTJ9C9Kb.js → templates-CK72aKO1.js} +14 -23
  57. package/dist/templates-CK72aKO1.js.map +1 -0
  58. package/dist/types/form-builder.d.ts +1 -1
  59. package/dist/{useControlSchema-CXhxTul7.js → useControlSchema-_OVhavKq.js} +280 -63
  60. package/dist/useControlSchema-_OVhavKq.js.map +1 -0
  61. package/dist/{useDropdownState-DSb3BC3N.js → useDropdownState-D5G4UvWd.js} +2 -2
  62. package/dist/{useDropdownState-DSb3BC3N.js.map → useDropdownState-D5G4UvWd.js.map} +1 -1
  63. package/dist/{useExperimentSelector-D0pjj9WH.js → useExperimentSelector-DSEckf_g.js} +3 -3
  64. package/dist/{useExperimentSelector-D0pjj9WH.js.map → useExperimentSelector-DSEckf_g.js.map} +1 -1
  65. package/dist/{useFormBuilder-CuOFB3aP.js → useFormBuilder-Bd714C7y.js} +2 -2
  66. package/dist/{useFormBuilder-CuOFB3aP.js.map → useFormBuilder-Bd714C7y.js.map} +1 -1
  67. package/dist/{useProtocolTemplates-BWhQIWV5.js → useProtocolTemplates-Dq5Pzwyd.js} +481 -46
  68. package/dist/useProtocolTemplates-Dq5Pzwyd.js.map +1 -0
  69. package/dist/utils/pluginSecrets.d.ts +53 -0
  70. package/dist/utils/records.d.ts +4 -0
  71. package/package.json +1 -1
  72. package/src/__tests__/components/AppAvatarMenu.test.ts +123 -0
  73. package/src/__tests__/components/BaseTabs.test.ts +67 -0
  74. package/src/__tests__/components/DropdownButton.test.ts +134 -0
  75. package/src/__tests__/components/PluginWorkspaceView.internal.test.ts +230 -0
  76. package/src/__tests__/components/SecretInput.test.ts +87 -0
  77. package/src/__tests__/components/WellEditPopupInternal.test.ts +108 -0
  78. package/src/__tests__/composables/usePluginClient.test.ts +140 -0
  79. package/src/components/AppAvatarMenu.vue +34 -3
  80. package/src/components/BaseModal.vue +8 -56
  81. package/src/components/BaseTabs.vue +26 -1
  82. package/src/components/BioTemplateExperimentWorkspaceView.vue +10 -21
  83. package/src/components/BioTemplatePackWorkspaceView.vue +11 -15
  84. package/src/components/BioTemplatePresetWorkspaceView.vue +8 -13
  85. package/src/components/ComponentBindingRenderer.vue +1 -3
  86. package/src/components/ControlWorkspaceView.vue +11 -22
  87. package/src/components/DoseDesignWorkspaceView.vue +6 -25
  88. package/src/components/DropdownButton.vue +32 -2
  89. package/src/components/PluginWorkspaceView.props.ts +38 -0
  90. package/src/components/PluginWorkspaceView.vue +53 -224
  91. package/src/components/SecretInput.story.vue +79 -0
  92. package/src/components/SecretInput.vue +150 -0
  93. package/src/components/index.ts +9 -0
  94. package/src/components/internal/WellEditPopupInternal.vue +20 -1
  95. package/src/components/internal/WorkspaceControlsInternal.vue +20 -0
  96. package/src/components/internal/WorkspaceShellInternal.vue +125 -0
  97. package/src/components/internal/WorkspaceSidebarInternal.vue +82 -0
  98. package/src/composables/experimentDesignData.ts +1 -3
  99. package/src/composables/flattenWorkspaceBindings.ts +104 -0
  100. package/src/composables/formBuilderRegistry.ts +5 -0
  101. package/src/composables/index.ts +26 -0
  102. package/src/composables/pluginWorkspaceModel.ts +182 -0
  103. package/src/composables/useAsync.ts +5 -1
  104. package/src/composables/useBioTemplatePackWorkspace.ts +15 -9
  105. package/src/composables/useBioTemplatePresetWorkspace.ts +14 -15
  106. package/src/composables/useBioTemplateWorkspace.ts +18 -43
  107. package/src/composables/useCommandHistory.ts +5 -1
  108. package/src/composables/useFocusTrap.ts +91 -0
  109. package/src/composables/useMenuKeyboard.ts +90 -0
  110. package/src/composables/useOptimisticMutation.ts +5 -1
  111. package/src/composables/usePluginClient.ts +42 -4
  112. package/src/composables/usePluginWorkspace.ts +208 -0
  113. package/src/composables/useResourceCrud.ts +5 -1
  114. package/src/composables/useRovingFocus.ts +66 -0
  115. package/src/composables/useWellPainting.ts +5 -1
  116. package/src/composables/useWellPlateAdapter.ts +5 -1
  117. package/src/composables/useWellPlateValidation.ts +5 -1
  118. package/src/composables/workspaceRecipes.ts +267 -0
  119. package/src/index.ts +16 -0
  120. package/src/styles/components/secret-input.css +60 -0
  121. package/src/styles/index.css +1 -0
  122. package/src/templates/builderReadUtils.ts +1 -3
  123. package/src/templates/componentTargetResolvers.ts +1 -3
  124. package/src/templates/controlSchemaTargets.ts +1 -3
  125. package/src/types/form-builder.ts +1 -0
  126. package/src/utils/formModelSync.ts +1 -3
  127. package/src/utils/pluginIcon.ts +1 -3
  128. package/src/utils/pluginSecrets.ts +102 -0
  129. package/src/utils/records.ts +9 -0
  130. package/dist/SettingsModal-CkNM8qAI.js.map +0 -1
  131. package/dist/components-MGNL6tRn.js.map +0 -1
  132. package/dist/composables-CKxOcvgh.js.map +0 -1
  133. package/dist/experiment-utils-CLnzPPq5.js.map +0 -1
  134. package/dist/templates-BTJ9C9Kb.js.map +0 -1
  135. package/dist/useControlSchema-CXhxTul7.js.map +0 -1
  136. package/dist/useProtocolTemplates-BWhQIWV5.js.map +0 -1
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import SecretInput from '../../components/SecretInput.vue'
4
+ import {
5
+ isPluginSecretRef,
6
+ keepPluginSecret,
7
+ pluginSecretLabel,
8
+ pluginSecretState,
9
+ } from '../../utils/pluginSecrets'
10
+
11
+ const storedRef = { $secret: 'sec_0f2a4c6e8a0c2e4a6c8e0a2c4e6a8c0e', set: true }
12
+ const lockedRef = { $secret: null, locked: true }
13
+
14
+ describe('pluginSecrets helpers', () => {
15
+ it('should classify each marker the settings response can publish', () => {
16
+ expect([
17
+ pluginSecretState(storedRef),
18
+ pluginSecretState(null),
19
+ pluginSecretState(lockedRef),
20
+ pluginSecretLabel(storedRef),
21
+ pluginSecretLabel(null),
22
+ pluginSecretLabel(lockedRef),
23
+ ]).toEqual(['set', 'unset', 'environment', 'Set', 'Not set', 'Provided by environment'])
24
+ })
25
+
26
+ it('should only treat reserved marker shapes as references', () => {
27
+ expect([
28
+ isPluginSecretRef(storedRef),
29
+ isPluginSecretRef({ $mint: 'placeholder' }),
30
+ isPluginSecretRef({ $secret: 'x', label: 'not a marker' }),
31
+ isPluginSecretRef('plain-string'),
32
+ keepPluginSecret(storedRef),
33
+ keepPluginSecret('typed'),
34
+ ]).toEqual([true, false, false, false, { ...storedRef }, null])
35
+ })
36
+ })
37
+
38
+ describe('SecretInput', () => {
39
+ it('should show the stored state without exposing a value', () => {
40
+ const wrapper = mount(SecretInput, { props: { modelValue: storedRef } })
41
+
42
+ expect([
43
+ wrapper.find('.mint-secret-input__badge').text(),
44
+ wrapper.find('input').exists(),
45
+ ]).toEqual(['Set', false])
46
+ })
47
+
48
+ it('should submit the typed string once the user enters a new value', async () => {
49
+ const wrapper = mount(SecretInput, { props: { modelValue: storedRef } })
50
+
51
+ await wrapper.findAll('button')[0]!.trigger('click')
52
+ await wrapper.setProps({ modelValue: '' })
53
+ await wrapper.find('input').setValue('typed-secret')
54
+
55
+ expect(wrapper.emitted('update:modelValue')).toEqual([[''], ['typed-secret']])
56
+ })
57
+
58
+ it('should submit null when the user clears a stored secret', async () => {
59
+ const wrapper = mount(SecretInput, { props: { modelValue: storedRef } })
60
+
61
+ const clear = wrapper.findAll('button').at(-1)!
62
+ await clear.trigger('click')
63
+
64
+ expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
65
+ })
66
+
67
+ it('should restore the stored reference when an edit is cancelled', async () => {
68
+ const wrapper = mount(SecretInput, { props: { modelValue: storedRef } })
69
+
70
+ await wrapper.findAll('button')[0]!.trigger('click')
71
+ await wrapper.setProps({ modelValue: 'partial' })
72
+ await wrapper.findAll('button')[0]!.trigger('click')
73
+
74
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{ ...storedRef }])
75
+ })
76
+
77
+ it('should keep an environment-provided secret read-only', async () => {
78
+ const wrapper = mount(SecretInput, { props: { modelValue: lockedRef } })
79
+
80
+ expect([
81
+ wrapper.find('.mint-secret-input__badge').text(),
82
+ wrapper.find('.mint-secret-input__hint').exists(),
83
+ wrapper.findAll('button').every(button => button.attributes('disabled') !== undefined),
84
+ wrapper.emitted('update:modelValue'),
85
+ ]).toEqual(['Provided by environment', true, true, undefined])
86
+ })
87
+ })
@@ -0,0 +1,108 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+ import WellEditPopupInternal from '../../components/internal/WellEditPopupInternal.vue'
4
+
5
+ function mountPopup(attachTo?: HTMLElement) {
6
+ return mount(WellEditPopupInternal, {
7
+ props: {
8
+ wellId: 'A1',
9
+ position: { x: 10, y: 10 },
10
+ },
11
+ attachTo,
12
+ global: {
13
+ stubs: { teleport: true },
14
+ },
15
+ })
16
+ }
17
+
18
+ describe('WellEditPopupInternal focus trap', () => {
19
+ it('moves focus into the popup on mount', async () => {
20
+ const host = document.createElement('div')
21
+ document.body.appendChild(host)
22
+ const wrapper = mountPopup(host)
23
+ await wrapper.vm.$nextTick()
24
+ await wrapper.vm.$nextTick()
25
+
26
+ expect(wrapper.find('.mint-well-edit-popup').element.contains(document.activeElement)).toBe(true)
27
+
28
+ wrapper.unmount()
29
+ host.remove()
30
+ })
31
+
32
+ it('restores focus to the previously focused element on unmount', async () => {
33
+ const host = document.createElement('div')
34
+ document.body.appendChild(host)
35
+ const trigger = document.createElement('button')
36
+ host.appendChild(trigger)
37
+ trigger.focus()
38
+ expect(document.activeElement).toBe(trigger)
39
+
40
+ const wrapper = mountPopup(host)
41
+ await wrapper.vm.$nextTick()
42
+ await wrapper.vm.$nextTick()
43
+ expect(document.activeElement).not.toBe(trigger)
44
+
45
+ wrapper.unmount()
46
+ expect(document.activeElement).toBe(trigger)
47
+
48
+ host.remove()
49
+ })
50
+
51
+ it('wraps Tab from the last focusable element back to the first', async () => {
52
+ const host = document.createElement('div')
53
+ document.body.appendChild(host)
54
+ const wrapper = mountPopup(host)
55
+ await wrapper.vm.$nextTick()
56
+ await wrapper.vm.$nextTick()
57
+
58
+ const focusable = (wrapper.element as HTMLElement).querySelectorAll<HTMLElement>(
59
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
60
+ )
61
+ expect(focusable.length).toBeGreaterThan(1)
62
+ const last = focusable[focusable.length - 1]
63
+ const first = focusable[0]
64
+ last.focus()
65
+
66
+ const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
67
+ document.dispatchEvent(event)
68
+
69
+ expect(document.activeElement).toBe(first)
70
+
71
+ wrapper.unmount()
72
+ host.remove()
73
+ })
74
+
75
+ it('wraps Shift+Tab from the first focusable element back to the last', async () => {
76
+ const host = document.createElement('div')
77
+ document.body.appendChild(host)
78
+ const wrapper = mountPopup(host)
79
+ await wrapper.vm.$nextTick()
80
+ await wrapper.vm.$nextTick()
81
+
82
+ const focusable = (wrapper.element as HTMLElement).querySelectorAll<HTMLElement>(
83
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
84
+ )
85
+ const first = focusable[0]
86
+ const last = focusable[focusable.length - 1]
87
+ first.focus()
88
+
89
+ const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true })
90
+ document.dispatchEvent(event)
91
+
92
+ expect(document.activeElement).toBe(last)
93
+
94
+ wrapper.unmount()
95
+ host.remove()
96
+ })
97
+
98
+ it('closes on Escape', async () => {
99
+ const wrapper = mountPopup()
100
+ await wrapper.vm.$nextTick()
101
+
102
+ const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
103
+ document.dispatchEvent(event)
104
+
105
+ expect(wrapper.emitted('close')).toHaveLength(1)
106
+ wrapper.unmount()
107
+ })
108
+ })
@@ -848,6 +848,146 @@ describe('usePluginClient', () => {
848
848
  })
849
849
  })
850
850
 
851
+ describe('secret settings references', () => {
852
+ const secretRef = { $secret: 'sec_0f2a4c6e8a0c2e4a6c8e0a2c4e6a8c0e', set: true }
853
+
854
+ function integratedPluginSettingsHost(): void {
855
+ ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
856
+ isIntegrated: true,
857
+ theme: 'light',
858
+ platformApiUrl: '/api',
859
+ plugin: {
860
+ id: 'drp',
861
+ name: 'drp',
862
+ version: '1.0.0',
863
+ route_prefix: '/drp',
864
+ api_prefix: '/api/drp',
865
+ settings_api: 'plugin',
866
+ },
867
+ }
868
+ nextGetData = {
869
+ settings: { apiKey: secretRef, method: 'first' },
870
+ secret_format: 'ref',
871
+ mode: 'integrated',
872
+ revision: 'revision-1',
873
+ }
874
+ nextPutData = {
875
+ settings: { apiKey: secretRef, method: 'second' },
876
+ secret_format: 'ref',
877
+ mode: 'integrated',
878
+ revision: 'revision-2',
879
+ }
880
+ }
881
+
882
+ it('should round-trip an untouched secret reference unchanged', async () => {
883
+ integratedPluginSettingsHost()
884
+
885
+ const settings = usePluginSettings<{ apiKey?: unknown; method?: string }>({
886
+ pluginName: 'drp',
887
+ })
888
+ await settings.load()
889
+ const saved = await settings.save({ ...settings.values.value, method: 'second' })
890
+
891
+ expect({ saved, body: requestBodies[0] }).toEqual({
892
+ saved: true,
893
+ body: { apiKey: secretRef, method: 'second', secret_format: 'ref' },
894
+ })
895
+ })
896
+
897
+ it('should submit a typed string when the user enters a new secret', async () => {
898
+ integratedPluginSettingsHost()
899
+
900
+ const settings = usePluginSettings<{ apiKey?: unknown; method?: string }>({
901
+ pluginName: 'drp',
902
+ })
903
+ await settings.load()
904
+ await settings.save({ apiKey: 'typed-secret', method: 'first' })
905
+
906
+ expect(requestBodies[0]).toEqual({
907
+ apiKey: 'typed-secret',
908
+ method: 'first',
909
+ secret_format: 'ref',
910
+ })
911
+ })
912
+
913
+ it('should submit null when the user clears a secret', async () => {
914
+ integratedPluginSettingsHost()
915
+
916
+ const settings = usePluginSettings<{ apiKey?: unknown; method?: string }>({
917
+ pluginName: 'drp',
918
+ })
919
+ await settings.load()
920
+ await settings.save({ apiKey: null, method: 'first' })
921
+
922
+ expect(requestBodies[0]).toEqual({
923
+ apiKey: null,
924
+ method: 'first',
925
+ secret_format: 'ref',
926
+ })
927
+ })
928
+
929
+ it('should declare the reference format on platform config patches', async () => {
930
+ ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
931
+ isIntegrated: true,
932
+ theme: 'light',
933
+ platformApiUrl: '/api',
934
+ plugin: {
935
+ id: 'drp',
936
+ name: 'drp',
937
+ version: '1.0.0',
938
+ route_prefix: '/drp',
939
+ api_prefix: '/api/drp',
940
+ },
941
+ }
942
+ nextGetData = {
943
+ plugin_name: 'drp',
944
+ config: { apiKey: secretRef, method: 'first' },
945
+ secret_format: 'ref',
946
+ revision: 'revision-1',
947
+ }
948
+ nextPatchData = {
949
+ plugin_name: 'drp',
950
+ config: { apiKey: secretRef, method: 'second' },
951
+ secret_format: 'ref',
952
+ revision: 'revision-2',
953
+ }
954
+
955
+ const settings = usePluginSettings<{ apiKey?: unknown; method?: string }>({
956
+ pluginName: 'drp',
957
+ })
958
+ await settings.load()
959
+ await settings.save({ apiKey: secretRef, method: 'second' })
960
+
961
+ expect(requestBodies[0]).toEqual({
962
+ config: { method: 'second', secret_format: 'ref' },
963
+ })
964
+ })
965
+
966
+ it('should omit the format marker for a host that does not publish one', async () => {
967
+ ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
968
+ isIntegrated: true,
969
+ theme: 'light',
970
+ platformApiUrl: '/api',
971
+ plugin: {
972
+ id: 'drp',
973
+ name: 'drp',
974
+ version: '1.0.0',
975
+ route_prefix: '/drp',
976
+ api_prefix: '/api/drp',
977
+ settings_api: 'plugin',
978
+ },
979
+ }
980
+ nextGetData = { settings: { method: 'first' }, mode: 'integrated', revision: 'revision-1' }
981
+ nextPutData = { settings: { method: 'second' }, mode: 'integrated', revision: 'revision-2' }
982
+
983
+ const settings = usePluginSettings<{ method?: string }>({ pluginName: 'drp' })
984
+ await settings.load()
985
+ await settings.save({ method: 'second' })
986
+
987
+ expect(requestBodies[0]).toEqual({ method: 'second' })
988
+ })
989
+ })
990
+
851
991
  it('should preserve the contract v2 form schema in settingsConfig', () => {
852
992
  const settings = usePluginSettings({
853
993
  apiBaseUrl: '/api/drp',
@@ -1,7 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  /** Avatar-triggered dropdown with user name/email header, configurable menu items, dividers, and a sign-out action. */
3
- import { computed } from 'vue'
3
+ import { computed, nextTick, ref } from 'vue'
4
4
  import { useDropdownState } from '../composables/useDropdownState'
5
+ import { useMenuKeyboard } from '../composables/useMenuKeyboard'
5
6
  import type { AccountMenuItem } from '../types/components'
6
7
  import ActionItemInternal from './internal/ActionItemInternal.vue'
7
8
 
@@ -27,11 +28,33 @@ const emit = defineEmits<{
27
28
  'menu-close': []
28
29
  }>()
29
30
 
30
- const { isOpen, rootRef, close, toggle } = useDropdownState({
31
+ const { isOpen, rootRef, open, close: closeDropdown, toggle } = useDropdownState({
31
32
  onOpen: () => emit('menu-open'),
32
33
  onClose: () => emit('menu-close'),
33
34
  })
34
35
 
36
+ const triggerRef = ref<HTMLElement | null>(null)
37
+ const panelRef = ref<HTMLElement | null>(null)
38
+
39
+ function menuItems(): HTMLElement[] {
40
+ if (!panelRef.value) return []
41
+ return Array.from(panelRef.value.querySelectorAll<HTMLElement>('[role="menuitem"]'))
42
+ }
43
+
44
+ function close(returnFocus = false) {
45
+ closeDropdown()
46
+ if (returnFocus) {
47
+ void nextTick(() => triggerRef.value?.focus())
48
+ }
49
+ }
50
+
51
+ const { handleTriggerKeydown, handlePanelKeydown } = useMenuKeyboard({
52
+ items: menuItems,
53
+ isOpen: () => isOpen.value,
54
+ open,
55
+ close,
56
+ })
57
+
35
58
  const initials = computed(() => {
36
59
  if (props.userInitial) return props.userInitial.slice(0, 2).toUpperCase()
37
60
  if (props.userName) {
@@ -64,6 +87,7 @@ function handleSignOut() {
64
87
  <template>
65
88
  <div ref="rootRef" class="mint-avatar-menu">
66
89
  <button
90
+ ref="triggerRef"
67
91
  type="button"
68
92
  class="mint-avatar-menu__trigger"
69
93
  :class="{ 'mint-avatar-menu__trigger--open': isOpen }"
@@ -71,6 +95,7 @@ function handleSignOut() {
71
95
  aria-haspopup="menu"
72
96
  :title="userName ? `${userName} · Account menu` : 'Account menu'"
73
97
  @click.stop="toggle"
98
+ @keydown="handleTriggerKeydown"
74
99
  >
75
100
  <span class="mint-avatar-menu__avatar">
76
101
  <slot name="avatar" :initials="initials">
@@ -80,7 +105,13 @@ function handleSignOut() {
80
105
  <span v-if="userName" class="mint-avatar-menu__name">{{ userName }}</span>
81
106
  </button>
82
107
 
83
- <div v-if="isOpen" class="mint-avatar-menu__panel" role="menu">
108
+ <div
109
+ v-if="isOpen"
110
+ ref="panelRef"
111
+ class="mint-avatar-menu__panel"
112
+ role="menu"
113
+ @keydown="handlePanelKeydown"
114
+ >
84
115
  <div v-if="userName || userEmail" class="mint-avatar-menu__header">
85
116
  <div v-if="userName" class="mint-avatar-menu__user-name">{{ userName }}</div>
86
117
  <div v-if="userEmail" class="mint-avatar-menu__user-email">{{ userEmail }}</div>
@@ -1,8 +1,9 @@
1
1
  <script setup lang="ts">
2
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'
3
+ import { computed, ref, toRef, watch, onUnmounted, type ComponentPublicInstance } from 'vue'
4
4
  import type { ModalLayout, ModalSize, ModalTab, ModalVariant } from '../types'
5
5
  import { useEventListener } from '../composables/useEventListener'
6
+ import { useFocusTrap } from '../composables/useFocusTrap'
6
7
 
7
8
  interface Props {
8
9
  modelValue: boolean
@@ -75,47 +76,16 @@ function isPathIcon(icon: ModalTab['icon']): icon is string | string[] {
75
76
  }
76
77
 
77
78
  const containerRef = ref<HTMLElement | null>(null)
78
- let previouslyFocused: HTMLElement | null = null
79
79
 
80
- const FOCUSABLE_SELECTOR = [
81
- 'a[href]',
82
- 'button:not([disabled])',
83
- 'input:not([disabled])',
84
- 'select:not([disabled])',
85
- 'textarea:not([disabled])',
86
- '[tabindex]:not([tabindex="-1"])',
87
- ].join(', ')
80
+ useFocusTrap({ containerRef, active: toRef(props, 'modelValue') })
88
81
 
89
- function getFocusableElements(): HTMLElement[] {
90
- if (!containerRef.value) return []
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)
94
- }
82
+ watch(() => props.modelValue, (isOpen) => {
83
+ document.body.style.overflow = isOpen ? 'hidden' : ''
84
+ })
95
85
 
96
- function handleKeydown(event: KeyboardEvent) {
86
+ function handleEscapeKeydown(event: KeyboardEvent) {
97
87
  if (event.key === 'Escape' && props.closeOnEscape && props.modelValue) {
98
88
  close()
99
- return
100
- }
101
-
102
- if (event.key !== 'Tab' || !containerRef.value) return
103
-
104
- const focusable = getFocusableElements()
105
- if (focusable.length === 0) {
106
- event.preventDefault()
107
- return
108
- }
109
-
110
- const first = focusable[0]
111
- const last = focusable[focusable.length - 1]
112
-
113
- if (event.shiftKey && document.activeElement === first) {
114
- event.preventDefault()
115
- last.focus()
116
- } else if (!event.shiftKey && document.activeElement === last) {
117
- event.preventDefault()
118
- first.focus()
119
89
  }
120
90
  }
121
91
 
@@ -132,25 +102,7 @@ function handleOverlayClick(event: MouseEvent) {
132
102
  }
133
103
  }
134
104
 
135
- watch(() => props.modelValue, async (isOpen) => {
136
- if (isOpen) {
137
- previouslyFocused = document.activeElement as HTMLElement | null
138
- document.body.style.overflow = 'hidden'
139
- await nextTick()
140
- const focusable = getFocusableElements()
141
- if (focusable.length > 0) {
142
- focusable[0].focus()
143
- } else {
144
- containerRef.value?.focus()
145
- }
146
- } else {
147
- document.body.style.overflow = ''
148
- previouslyFocused?.focus()
149
- previouslyFocused = null
150
- }
151
- })
152
-
153
- useEventListener(() => document, 'keydown', handleKeydown)
105
+ useEventListener(() => document, 'keydown', handleEscapeKeydown)
154
106
 
155
107
  onUnmounted(() => {
156
108
  document.body.style.overflow = ''
@@ -1,8 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  /** Renders a tab bar with underline or pills variant, badges, icons, and disabled tab support. */
3
- import { computed } from 'vue'
3
+ import { computed, type ComponentPublicInstance } from 'vue'
4
4
  import type { TabItem, TabItemInput } from '../types'
5
5
  import { normalizeItemInput } from '../utils/items'
6
+ import { useRovingFocus } from '../composables/useRovingFocus'
6
7
 
7
8
  interface Props {
8
9
  modelValue: string
@@ -32,23 +33,47 @@ function selectTab(tabId: string) {
32
33
  emit('update:modelValue', tabId)
33
34
  }
34
35
  }
36
+
37
+ const tabButtonRefs = new Map<string, HTMLElement>()
38
+
39
+ function setTabButtonRef(id: string, el: Element | ComponentPublicInstance | null) {
40
+ if (el instanceof HTMLElement) {
41
+ tabButtonRefs.set(id, el)
42
+ } else {
43
+ tabButtonRefs.delete(id)
44
+ }
45
+ }
46
+
47
+ // Left/Right only — BaseTabs renders a single horizontal strip with no vertical
48
+ // variant, so there is no Up/Down axis to support.
49
+ const { handleKeydown } = useRovingFocus({
50
+ ids: () => normalizedTabs.value.filter(tab => !tab.disabled).map(tab => tab.id),
51
+ activeId: () => activeTab.value,
52
+ activate: selectTab,
53
+ getElement: (id) => tabButtonRefs.get(id),
54
+ })
35
55
  </script>
36
56
 
37
57
  <template>
38
58
  <div :class="['mint-tabs', `mint-tabs--${variant}`]" role="tablist">
39
59
  <button
40
60
  v-for="tab in normalizedTabs"
61
+ :id="`mint-tab-${tab.id}`"
41
62
  :key="tab.id"
63
+ :ref="(el) => setTabButtonRef(tab.id, el)"
42
64
  type="button"
43
65
  role="tab"
44
66
  :aria-selected="activeTab === tab.id"
45
67
  :aria-disabled="tab.disabled"
68
+ :aria-controls="`mint-tabpanel-${tab.id}`"
69
+ :tabindex="activeTab === tab.id ? 0 : -1"
46
70
  :class="[
47
71
  'mint-tab',
48
72
  activeTab === tab.id ? 'mint-tab--active' : '',
49
73
  tab.disabled ? 'mint-tab--disabled' : '',
50
74
  ]"
51
75
  @click="selectTab(tab.id)"
76
+ @keydown="handleKeydown"
52
77
  >
53
78
  <span class="mint-tab__content">
54
79
  <svg
@@ -19,6 +19,7 @@ import {
19
19
  type BioTemplateComponentPropsLookupOptions,
20
20
  type TemplateCollectionEnvelope,
21
21
  } from '../templates'
22
+ import { bioTemplateExperimentWorkspaceProps } from '../composables/workspaceRecipes'
22
23
  import AlertBox from './AlertBox.vue'
23
24
  import BaseButton from './BaseButton.vue'
24
25
  import BioTemplateRenderer from './BioTemplateRenderer.vue'
@@ -114,27 +115,15 @@ const statusMessage = computed(() => {
114
115
  return `${props.label} template data saves to experiment design_data.`
115
116
  })
116
117
 
117
- const resolvedLoading = computed(() => {
118
- if (props.status?.loading !== undefined || props.status?.saving !== undefined) {
119
- return Boolean(props.status.loading) || Boolean(props.status.saving)
120
- }
121
- return props.loading
122
- })
123
- const resolvedError = computed(() =>
124
- props.status?.error !== undefined ? props.status.error : props.error,
125
- )
126
- const resolvedHasExperiment = computed(() =>
127
- props.status?.hasExperiment ?? props.hasExperiment,
128
- )
129
- const resolvedCurrentExperimentId = computed(() =>
130
- props.status?.currentExperimentId ?? props.currentExperimentId,
131
- )
132
- const resolvedLastLoadedAt = computed(() =>
133
- props.status?.lastLoadedAt !== undefined ? props.status.lastLoadedAt : props.lastLoadedAt,
134
- )
135
- const resolvedLastSavedAt = computed(() =>
136
- props.status?.lastSavedAt !== undefined ? props.status.lastSavedAt : props.lastSavedAt,
137
- )
118
+ // Thin recipe over bioTemplateExperimentWorkspaceProps(): merges the grouped `status` prop
119
+ // over the individual loading/error/experiment props (see workspaceRecipes.ts).
120
+ const resolvedStatus = computed(() => bioTemplateExperimentWorkspaceProps(props))
121
+ const resolvedLoading = computed(() => resolvedStatus.value.loading)
122
+ const resolvedError = computed(() => resolvedStatus.value.error)
123
+ const resolvedHasExperiment = computed(() => resolvedStatus.value.hasExperiment)
124
+ const resolvedCurrentExperimentId = computed(() => resolvedStatus.value.currentExperimentId)
125
+ const resolvedLastLoadedAt = computed(() => resolvedStatus.value.lastLoadedAt)
126
+ const resolvedLastSavedAt = computed(() => resolvedStatus.value.lastSavedAt)
138
127
 
139
128
  const templateSummaries = computed(() =>
140
129
  Object.values(extractTemplateCollection(props.target)).map(template => ({
@@ -14,6 +14,7 @@ import type {
14
14
  UseBioTemplatePackWorkspaceReturn,
15
15
  } from '../composables/useBioTemplatePackWorkspace'
16
16
  import { useBioTemplatePackWorkspace } from '../composables/useBioTemplatePackWorkspace'
17
+ import { bioTemplatePackWorkspaceProps } from '../composables/workspaceRecipes'
17
18
  import BioTemplateExperimentWorkspaceView from './BioTemplateExperimentWorkspaceView.vue'
18
19
 
19
20
  interface BioTemplatePackWorkspaceSlotProps {
@@ -140,6 +141,15 @@ const resolvedActions = computed<PackWorkspaceActions>(() => ({
140
141
  save: props.actions?.save ?? (() => resolvedWorkspace.value.saveCurrent()),
141
142
  }))
142
143
 
144
+ // Thin recipe over bioTemplatePackWorkspaceProps(): forwards this view's own display props
145
+ // plus the pack-derived target/label/status/actions to BioTemplateExperimentWorkspaceView.
146
+ const experimentWorkspaceRecipe = computed(() => bioTemplatePackWorkspaceProps(props, {
147
+ target: target.value,
148
+ label: label.value,
149
+ status: resolvedStatus.value,
150
+ actions: resolvedActions.value,
151
+ }))
152
+
143
153
  watch(
144
154
  [() => props.pack, () => props.workspaceOptions],
145
155
  () => {
@@ -152,21 +162,7 @@ onScopeDispose(() => generatedWorkspaceScope?.stop())
152
162
  </script>
153
163
 
154
164
  <template>
155
- <BioTemplateExperimentWorkspaceView
156
- :target="target"
157
- kind="collection"
158
- :label="label"
159
- :message="message"
160
- :status="resolvedStatus"
161
- :actions="resolvedActions"
162
- :dense="dense"
163
- :readonly="readonly"
164
- :show-status="showStatus"
165
- :show-template-summary="showTemplateSummary"
166
- :load-label="loadLabel"
167
- :reset-label="resetLabel"
168
- :save-label="saveLabel"
169
- >
165
+ <BioTemplateExperimentWorkspaceView v-bind="experimentWorkspaceRecipe">
170
166
  <template v-if="hasDefaultSlot" #default="slotProps">
171
167
  <slot
172
168
  v-bind="slotProps"