@morscherlab/mint-sdk 1.4.0-beta.9 → 1.4.1

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 (198) hide show
  1. package/README.md +14 -3
  2. package/dist/{AlertBox-B2Bgr3ei.js → AlertBox-DwgNTEqf.js} +3 -3
  3. package/dist/{AlertBox-B2Bgr3ei.js.map → AlertBox-DwgNTEqf.js.map} +1 -1
  4. package/dist/{AppSidebar-Dq9W1jE2.js → AppSidebar-CZZaxni0.js} +1103 -144
  5. package/dist/AppSidebar-CZZaxni0.js.map +1 -0
  6. package/dist/{ConfirmDialog-nbJj51is.js → ConfirmDialog-CjWoxAD1.js} +2 -2
  7. package/dist/{ConfirmDialog-nbJj51is.js.map → ConfirmDialog-CjWoxAD1.js.map} +1 -1
  8. package/dist/{ExperimentPopover-5T1fAq2L.js → ExperimentPopover-DujiuQIO.js} +5 -5
  9. package/dist/{ExperimentPopover-5T1fAq2L.js.map → ExperimentPopover-DujiuQIO.js.map} +1 -1
  10. package/dist/{ExperimentSelectorModal-65peG4Up.js → ExperimentSelectorModal-B4j4eUOJ.js} +114 -98
  11. package/dist/ExperimentSelectorModal-B4j4eUOJ.js.map +1 -0
  12. package/dist/{SegmentedControl-BtnnjWzs.js → SegmentedControl-DyUPH3I5.js} +2 -2
  13. package/dist/{SegmentedControl-BtnnjWzs.js.map → SegmentedControl-DyUPH3I5.js.map} +1 -1
  14. package/dist/{SettingsModal-483nyI6b.js → SettingsModal-D2MOOtuO.js} +26 -25
  15. package/dist/SettingsModal-D2MOOtuO.js.map +1 -0
  16. package/dist/__stories__/experiment-helpers.d.ts +10 -0
  17. package/dist/__tests__/components/AppTopBarOnboarding.test.d.ts +1 -0
  18. package/dist/__tests__/components/FloatingPanelsInModal.test.d.ts +1 -0
  19. package/dist/__tests__/composables/useFloatingPanel.test.d.ts +1 -0
  20. package/dist/__tests__/composables/useOnboarding.test.d.ts +1 -0
  21. package/dist/__tests__/styles/paletteTokens.test.d.ts +1 -0
  22. package/dist/__tests__/styles/v2AuditRules.test.d.ts +1 -0
  23. package/dist/components/AppTopBar.vue.d.ts +20 -5
  24. package/dist/components/BaseButton.vue.d.ts +4 -2
  25. package/dist/components/ExperimentSelectorModal.vue.d.ts +15 -1
  26. package/dist/components/JobsStatusTray.vue.d.ts +15 -4
  27. package/dist/components/PluginWorkspaceView.props.d.ts +3 -0
  28. package/dist/components/PluginWorkspaceView.vue.d.ts +3 -0
  29. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +3 -3
  30. package/dist/components/internal/AppTopBarOnboardingInternal.vue.d.ts +7 -0
  31. package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -1
  32. package/dist/components/internal/AutoGroupModalInternal.vue.d.ts +2 -2
  33. package/dist/components/internal/OnboardingLayerInternal.vue.d.ts +7 -0
  34. package/dist/components/internal/WorkspaceShellInternal.vue.d.ts +8 -0
  35. package/dist/composables/pluginWorkspaceModel.d.ts +2 -2
  36. package/dist/composables/useFileImport.d.ts +7 -0
  37. package/dist/composables/useFloatingPanel.d.ts +29 -0
  38. package/dist/composables/useJobCenter.d.ts +41 -3
  39. package/dist/composables/useOnboarding.d.ts +119 -0
  40. package/dist/{controlSchemaModel-B8CTXagP.js → controlSchemaModel-Bsg0t5Ab.js} +2 -2
  41. package/dist/{controlSchemaModel-B8CTXagP.js.map → controlSchemaModel-Bsg0t5Ab.js.map} +1 -1
  42. package/dist/{data-CgILxDLW.js → data-Boeh4uPI.js} +13 -12
  43. package/dist/{data-CgILxDLW.js.map → data-Boeh4uPI.js.map} +1 -1
  44. package/dist/data.d.ts +1 -1
  45. package/dist/data.js +9 -9
  46. package/dist/{experiment-43OJtH7R.js → experiment-DyO54FQB.js} +25 -61
  47. package/dist/experiment-DyO54FQB.js.map +1 -0
  48. package/dist/{experimentStatus-Cqp6qNrR.js → experimentStatus-DCn2QKG7.js} +2 -2
  49. package/dist/{experimentStatus-Cqp6qNrR.js.map → experimentStatus-DCn2QKG7.js.map} +1 -1
  50. package/dist/index.js +20 -20
  51. package/dist/{install-BtLWTk6H.js → install-BEHp7Mo-.js} +15 -15
  52. package/dist/{install-BtLWTk6H.js.map → install-BEHp7Mo-.js.map} +1 -1
  53. package/dist/install.js +1 -1
  54. package/dist/{lab-MHzp-_J8.js → lab-D61eaX6f.js} +68 -61
  55. package/dist/{lab-MHzp-_J8.js.map → lab-D61eaX6f.js.map} +1 -1
  56. package/dist/lab.js +4 -4
  57. package/dist/{records-B6gpM_3e.js → records-D4aQXyZu.js} +193 -133
  58. package/dist/records-D4aQXyZu.js.map +1 -0
  59. package/dist/{runtime-7vfl_bI3.js → runtime-q-R0CNLh.js} +400 -33
  60. package/dist/runtime-q-R0CNLh.js.map +1 -0
  61. package/dist/runtime.d.ts +1 -1
  62. package/dist/runtime.js +7 -7
  63. package/dist/{shell-Cs9OJMnm.js → shell-6CGFvUUw.js} +10 -5
  64. package/dist/{shell-Cs9OJMnm.js.map → shell-6CGFvUUw.js.map} +1 -1
  65. package/dist/shell.d.ts +1 -0
  66. package/dist/shell.js +6 -6
  67. package/dist/stores/index.d.ts +1 -1
  68. package/dist/stores/index.js +2 -2
  69. package/dist/stores/settings.d.ts +4 -12
  70. package/dist/styles.css +1191 -479
  71. package/dist/types/components.d.ts +2 -0
  72. package/dist/types/index.d.ts +1 -1
  73. package/dist/types/jobs.d.ts +9 -0
  74. package/dist/types/platform.d.ts +1 -1
  75. package/dist/{useDropdownState-8yWMu-7_.js → useDropdownState-uFIz6Yv8.js} +16 -4
  76. package/dist/useDropdownState-uFIz6Yv8.js.map +1 -0
  77. package/dist/{useExperimentSamples-DHnxKFAF.js → useExperimentSamples-QEFoq4tf.js} +48 -6
  78. package/dist/useExperimentSamples-QEFoq4tf.js.map +1 -0
  79. package/dist/{useListReorder-BitdUhxn.js → useListReorder-DsuzwWGS.js} +122 -172
  80. package/dist/useListReorder-DsuzwWGS.js.map +1 -0
  81. package/dist/{useRackEditor-DiwCt77M.js → useRackEditor-KpZ4iJ6o.js} +22 -32
  82. package/dist/useRackEditor-KpZ4iJ6o.js.map +1 -0
  83. package/dist/useRealtime-B58Lo-Tm.js +508 -0
  84. package/dist/useRealtime-B58Lo-Tm.js.map +1 -0
  85. package/dist/{useRequest-D506eHi8.js → useRequest-BuDmW8Jc.js} +8 -37
  86. package/dist/useRequest-BuDmW8Jc.js.map +1 -0
  87. package/package.json +3 -2
  88. package/src/__stories__/experiment-helpers.ts +3 -3
  89. package/src/__tests__/components/ActionMenu.test.ts +2 -2
  90. package/src/__tests__/components/AppTopBar.test.ts +206 -1
  91. package/src/__tests__/components/AppTopBarOnboarding.test.ts +160 -0
  92. package/src/__tests__/components/AppTopBarPageSelector.test.ts +125 -9
  93. package/src/__tests__/components/BaseButton.test.ts +19 -0
  94. package/src/__tests__/components/BaseModal.test.ts +13 -0
  95. package/src/__tests__/components/ExperimentSelectorModal.test.ts +34 -1
  96. package/src/__tests__/components/FloatingPanelsInModal.test.ts +94 -0
  97. package/src/__tests__/components/GeneratedPluginView.test.ts +1 -1
  98. package/src/__tests__/components/JobsStatusTray.test.ts +373 -71
  99. package/src/__tests__/components/PluginWorkspaceView.test.ts +63 -1
  100. package/src/__tests__/components/SampleSelector.test.ts +20 -0
  101. package/src/__tests__/components/SettingsModal.test.ts +9 -2
  102. package/src/__tests__/composables/useFileImport.test.ts +36 -0
  103. package/src/__tests__/composables/useFloatingPanel.test.ts +68 -0
  104. package/src/__tests__/composables/useJobCenter.test.ts +262 -3
  105. package/src/__tests__/composables/useOnboarding.test.ts +317 -0
  106. package/src/__tests__/docs/frontendDocsCatalog.test.ts +4 -3
  107. package/src/__tests__/fixtures/table-import/cells.xlsx +0 -0
  108. package/src/__tests__/fixtures/table-import/samples.xlsx +0 -0
  109. package/src/__tests__/stores/settings.test.ts +21 -21
  110. package/src/__tests__/styles/cssTokens.test.ts +4 -1
  111. package/src/__tests__/styles/menusShellRefresh.test.ts +8 -2
  112. package/src/__tests__/styles/paletteTokens.test.ts +44 -0
  113. package/src/__tests__/styles/v2AuditRules.test.ts +104 -0
  114. package/src/components/ActionMenu.vue +6 -37
  115. package/src/components/AppTopBar.onboarding.story.vue +69 -0
  116. package/src/components/AppTopBar.story.vue +59 -4
  117. package/src/components/AppTopBar.vue +132 -10
  118. package/src/components/BaseButton.vue +16 -2
  119. package/src/components/BaseModal.vue +2 -1
  120. package/src/components/ExperimentSelectorModal.story.vue +38 -0
  121. package/src/components/ExperimentSelectorModal.vue +20 -1
  122. package/src/components/GeneratedPluginView.vue +4 -9
  123. package/src/components/GeneratedResultRenderer.vue +3 -4
  124. package/src/components/JobsStatusTray.story.vue +97 -43
  125. package/src/components/JobsStatusTray.vue +287 -290
  126. package/src/components/LayoutResizeHandle.story.vue +65 -4
  127. package/src/components/PluginWorkspaceView.props.ts +3 -0
  128. package/src/components/PluginWorkspaceView.vue +17 -0
  129. package/src/components/SampleSelector.story.vue +21 -6
  130. package/src/components/SampleSelector.vue +10 -3
  131. package/src/components/SectionCard.story.vue +33 -0
  132. package/src/components/SettingsModal.vue +7 -12
  133. package/src/components/Tooltip.vue +9 -32
  134. package/src/components/internal/AppTopBarOnboardingInternal.vue +172 -0
  135. package/src/components/internal/AppTopBarPageSelectorInternal.vue +140 -72
  136. package/src/components/internal/DatePickerDateInternal.vue +7 -45
  137. package/src/components/internal/DatePickerDateTimeInternal.vue +7 -1
  138. package/src/components/internal/DatePickerTimeInternal.vue +8 -1
  139. package/src/components/internal/NumberUnitInputInternal.vue +4 -18
  140. package/src/components/internal/OnboardingLayerInternal.vue +173 -0
  141. package/src/components/internal/SelectMultipleInternal.vue +6 -1
  142. package/src/components/internal/SelectSearchableInternal.vue +6 -1
  143. package/src/components/internal/WellEditPopupInternal.vue +11 -22
  144. package/src/components/internal/WorkspaceShellInternal.vue +12 -0
  145. package/src/composables/pluginWorkspaceModel.ts +2 -2
  146. package/src/composables/useFileImport.ts +51 -1
  147. package/src/composables/useFloatingPanel.ts +107 -0
  148. package/src/composables/useJobCenter.ts +188 -6
  149. package/src/composables/useOnboarding.ts +465 -0
  150. package/src/composables/usePluginWorkspace.ts +1 -1
  151. package/src/data.ts +1 -0
  152. package/src/runtime.ts +2 -1
  153. package/src/shell.ts +17 -0
  154. package/src/stores/index.ts +0 -2
  155. package/src/stores/settings.ts +20 -50
  156. package/src/styles/components/action-menu.css +1 -1
  157. package/src/styles/components/app-layout.css +6 -3
  158. package/src/styles/components/app-page-selector.css +120 -45
  159. package/src/styles/components/app-top-bar.css +165 -3
  160. package/src/styles/components/button.css +8 -0
  161. package/src/styles/components/dataframe.css +1 -1
  162. package/src/styles/components/date-picker.css +4 -2
  163. package/src/styles/components/datetime-picker.css +4 -4
  164. package/src/styles/components/experiment-selector-modal.css +13 -0
  165. package/src/styles/components/jobs-status-tray.css +134 -296
  166. package/src/styles/components/layout-resize-handle.css +59 -16
  167. package/src/styles/components/modal.css +3 -3
  168. package/src/styles/components/number-input.css +1 -1
  169. package/src/styles/components/onboarding.css +368 -0
  170. package/src/styles/components/progress-bar.css +3 -3
  171. package/src/styles/components/sample-selector.css +43 -0
  172. package/src/styles/components/searchable-select.css +3 -7
  173. package/src/styles/components/section-card.css +30 -6
  174. package/src/styles/components/settings-modal.css +7 -2
  175. package/src/styles/components/time-picker.css +3 -6
  176. package/src/styles/components/toast.css +1 -1
  177. package/src/styles/components/tooltip.css +2 -2
  178. package/src/styles/components/unit-input.css +1 -1
  179. package/src/styles/components/well-edit-popup.css +2 -2
  180. package/src/styles/index.css +1 -0
  181. package/src/styles/variables.css +153 -15
  182. package/src/types/components.ts +2 -0
  183. package/src/types/index.ts +1 -0
  184. package/src/types/jobs.ts +10 -0
  185. package/src/types/platform.ts +1 -1
  186. package/dist/AppSidebar-Dq9W1jE2.js.map +0 -1
  187. package/dist/ExperimentSelectorModal-65peG4Up.js.map +0 -1
  188. package/dist/SettingsModal-483nyI6b.js.map +0 -1
  189. package/dist/experiment-43OJtH7R.js.map +0 -1
  190. package/dist/records-B6gpM_3e.js.map +0 -1
  191. package/dist/runtime-7vfl_bI3.js.map +0 -1
  192. package/dist/useDropdownState-8yWMu-7_.js.map +0 -1
  193. package/dist/useExperimentSamples-DHnxKFAF.js.map +0 -1
  194. package/dist/useListReorder-BitdUhxn.js.map +0 -1
  195. package/dist/useRackEditor-DiwCt77M.js.map +0 -1
  196. package/dist/useRealtime-C3iy9QkU.js +0 -699
  197. package/dist/useRealtime-C3iy9QkU.js.map +0 -1
  198. package/dist/useRequest-D506eHi8.js.map +0 -1
@@ -1,7 +1,8 @@
1
1
  <script setup lang="ts">
2
- /** Dropdown trigger that switches between named pages, closing on outside click or Escape. */
3
- import { computed, useTemplateRef } from 'vue'
2
+ /** "Plugin / page" switcher: the plugin name, then a trigger that opens the page menu (hints, number keys 1-9). */
3
+ import { computed, nextTick, useTemplateRef } from 'vue'
4
4
  import { useDropdownState } from '../../composables/useDropdownState'
5
+ import { useMenuKeyboard } from '../../composables/useMenuKeyboard'
5
6
  import type { PageSelectorItem, PageSelectorItemInput } from '../../types/components'
6
7
  import { normalizeItemInput } from '../../utils/items'
7
8
  import { isPluginIconFormat } from '../../utils/pluginIcon'
@@ -13,27 +14,74 @@ interface Props {
13
14
  currentPageId?: string
14
15
  placeholder?: string
15
16
  minWidth?: string
17
+ /** Plugin name shown before the trigger and in the menu title. */
18
+ pluginName?: string
16
19
  }
17
20
 
18
21
  const props = withDefaults(defineProps<Props>(), {
19
22
  placeholder: 'Go to…',
20
- minWidth: '11.25rem',
21
23
  })
22
24
 
23
25
  const emit = defineEmits<{
24
26
  select: [page: PageSelectorItem]
25
27
  }>()
26
28
 
27
- const { isOpen, close, toggle } = useDropdownState({
29
+ const triggerEl = useTemplateRef<HTMLElement>('trigger')
30
+ const menuEl = useTemplateRef<HTMLElement>('menu')
31
+ const { isOpen, open, close, toggle } = useDropdownState({
28
32
  rootRef: useTemplateRef<HTMLElement>('root'),
29
- triggerRef: useTemplateRef<HTMLElement>('trigger'),
33
+ triggerRef: triggerEl,
30
34
  })
31
35
  const normalizedPages = computed<PageSelectorItem[]>(() => props.pages.map(normalizeItemInput))
36
+ const menuTitle = computed(() => (props.pluginName ? `${props.pluginName} pages` : 'Pages'))
32
37
 
33
38
  const current = computed<PageSelectorItem | undefined>(() =>
34
39
  normalizedPages.value.find((p) => p.id === props.currentPageId) ?? normalizedPages.value[0],
35
40
  )
36
41
 
42
+ const rows = () => Array.from(menuEl.value?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])
43
+ const isEnabled = (row: HTMLElement) => !row.hasAttribute('disabled') && row.getAttribute('aria-disabled') !== 'true'
44
+ const enabledRows = () => rows().filter(isEnabled)
45
+
46
+ const { handlePanelKeydown } = useMenuKeyboard({
47
+ items: enabledRows,
48
+ isOpen: () => isOpen.value,
49
+ open,
50
+ close,
51
+ })
52
+
53
+ function handleTriggerKeydown(event: KeyboardEvent) {
54
+ if (isOpen.value || !['ArrowDown', 'Enter', ' '].includes(event.key)) return
55
+ event.preventDefault()
56
+ open()
57
+ void nextTick(() => {
58
+ const items = enabledRows()
59
+ ;(items.find((row) => row.getAttribute('aria-current') === 'page') ?? items[0])?.focus()
60
+ })
61
+ }
62
+
63
+ // A click routes through ActionItemInternal, so links, router links and disabled rows act as on a pointer click.
64
+ function activate(row: HTMLElement) {
65
+ row.click()
66
+ if (!isOpen.value) triggerEl.value?.focus()
67
+ }
68
+
69
+ function handleMenuKeydown(event: KeyboardEvent) {
70
+ if (/^[1-9]$/.test(event.key)) {
71
+ event.preventDefault()
72
+ const row = rows()[Number(event.key) - 1]
73
+ if (row && isEnabled(row)) activate(row)
74
+ return
75
+ }
76
+ const focused = document.activeElement as HTMLElement | null
77
+ if ((event.key === 'Enter' || event.key === ' ') && focused && enabledRows().includes(focused)) {
78
+ event.preventDefault()
79
+ activate(focused)
80
+ return
81
+ }
82
+ handlePanelKeydown(event)
83
+ }
84
+
37
85
  function handleSelect(page: PageSelectorItem) {
38
86
  if (page.disabled) return
39
87
  if (page.href || page.to) {
@@ -46,82 +94,102 @@ function handleSelect(page: PageSelectorItem) {
46
94
  </script>
47
95
 
48
96
  <template>
49
- <div ref="root" class="mint-page-selector" :style="{ minWidth }">
50
- <button
51
- ref="trigger"
52
- type="button"
53
- class="mint-page-selector__trigger"
54
- :class="{ 'mint-page-selector__trigger--open': isOpen }"
55
- :aria-expanded="isOpen"
56
- aria-haspopup="menu"
57
- @click.stop="toggle"
58
- >
59
- <span
60
- v-if="current"
61
- class="mint-page-selector__icon"
62
- :class="{ 'mint-page-selector__icon--metadata': isPluginIconFormat(current.icon) }"
63
- >
64
- <slot name="icon" :page="current">
65
- <PluginIcon
66
- v-if="isPluginIconFormat(current.icon)"
67
- class="mint-page-selector__trigger-metadata-icon"
68
- :icon="current.icon"
69
- size="sm"
70
- variant="solid"
71
- />
72
- <span v-else class="mint-page-selector__icon-fallback">{{ current.label.charAt(0) }}</span>
73
- </slot>
74
- </span>
75
- <span class="mint-page-selector__label">{{ current?.label ?? placeholder }}</span>
76
- <span class="mint-page-selector__chevron-wrap" aria-hidden="true">
77
- <svg
78
- class="mint-page-selector__chevron"
79
- :class="{ 'mint-page-selector__chevron--open': isOpen }"
80
- viewBox="0 0 24 24"
81
- fill="none"
82
- stroke="currentColor"
83
- stroke-width="2"
84
- stroke-linecap="round"
85
- stroke-linejoin="round"
86
- >
87
- <path d="m6 9 6 6 6-6" />
88
- </svg>
89
- </span>
90
- </button>
91
-
92
- <div v-if="isOpen" class="mint-page-selector__menu" role="menu">
93
- <div class="mint-page-selector__menu-title">Go to</div>
94
- <ActionItemInternal
95
- v-for="page in normalizedPages"
96
- :key="page.id"
97
- :href="page.href"
98
- :to="page.to"
99
- :disabled="page.disabled"
100
- :class="[
101
- 'mint-page-selector__item',
102
- { 'mint-page-selector__item--active': page.id === current?.id },
103
- { 'mint-page-selector__item--disabled': page.disabled },
104
- ]"
105
- role="menuitem"
106
- @click="handleSelect(page)"
97
+ <div ref="root" class="mint-page-selector__crumb">
98
+ <template v-if="pluginName">
99
+ <span class="mint-page-selector__plugin">{{ pluginName }}</span>
100
+ <span class="mint-page-selector__slash" aria-hidden="true">/</span>
101
+ </template>
102
+ <div class="mint-page-selector" :style="minWidth ? { minWidth } : undefined">
103
+ <button
104
+ ref="trigger"
105
+ type="button"
106
+ class="mint-page-selector__trigger"
107
+ :class="{ 'mint-page-selector__trigger--open': isOpen }"
108
+ :aria-expanded="isOpen"
109
+ aria-haspopup="menu"
110
+ @click.stop="toggle"
111
+ @keydown="handleTriggerKeydown"
107
112
  >
108
113
  <span
109
- class="mint-page-selector__item-icon"
110
- :class="{ 'mint-page-selector__item-icon--metadata': isPluginIconFormat(page.icon) }"
114
+ v-if="current"
115
+ class="mint-page-selector__icon"
116
+ :class="{ 'mint-page-selector__icon--metadata': isPluginIconFormat(current.icon) }"
111
117
  >
112
- <slot name="item-icon" :page="page">
118
+ <slot name="icon" :page="current">
113
119
  <PluginIcon
114
- v-if="isPluginIconFormat(page.icon)"
115
- class="mint-page-selector__metadata-icon"
116
- :icon="page.icon"
120
+ v-if="isPluginIconFormat(current.icon)"
121
+ class="mint-page-selector__trigger-metadata-icon"
122
+ :icon="current.icon"
117
123
  size="sm"
118
124
  variant="solid"
119
125
  />
120
- <span v-else class="mint-page-selector__icon-fallback">{{ page.label.charAt(0) }}</span>
126
+ <span v-else class="mint-page-selector__icon-fallback">{{ current.label.charAt(0) }}</span>
121
127
  </slot>
122
128
  </span>
123
- <span class="mint-page-selector__item-label">{{ page.label }}</span>
124
- </ActionItemInternal>
129
+ <span class="mint-page-selector__label">{{ current?.label ?? placeholder }}</span>
130
+ <span class="mint-page-selector__chevron-wrap" aria-hidden="true">
131
+ <svg
132
+ class="mint-page-selector__chevron"
133
+ :class="{ 'mint-page-selector__chevron--open': isOpen }"
134
+ viewBox="0 0 24 24"
135
+ fill="none"
136
+ stroke="currentColor"
137
+ stroke-width="2"
138
+ stroke-linecap="round"
139
+ stroke-linejoin="round"
140
+ >
141
+ <path d="m6 9 6 6 6-6" />
142
+ </svg>
143
+ </span>
144
+ </button>
145
+
146
+ <div
147
+ v-if="isOpen"
148
+ ref="menu"
149
+ class="mint-page-selector__menu"
150
+ role="menu"
151
+ :aria-label="menuTitle"
152
+ @keydown="handleMenuKeydown"
153
+ >
154
+ <div class="mint-page-selector__menu-title" aria-hidden="true">{{ menuTitle }}</div>
155
+ <ActionItemInternal
156
+ v-for="(page, index) in normalizedPages"
157
+ :key="page.id"
158
+ :href="page.href"
159
+ :to="page.to"
160
+ :disabled="page.disabled"
161
+ :class="[
162
+ 'mint-page-selector__item',
163
+ { 'mint-page-selector__item--active': page.id === current?.id },
164
+ { 'mint-page-selector__item--disabled': page.disabled },
165
+ ]"
166
+ role="menuitem"
167
+ :aria-current="page.id === current?.id ? 'page' : undefined"
168
+ :aria-keyshortcuts="index < 9 ? String(index + 1) : undefined"
169
+ @click="handleSelect(page)"
170
+ >
171
+ <span
172
+ class="mint-page-selector__item-icon"
173
+ :class="{ 'mint-page-selector__item-icon--metadata': isPluginIconFormat(page.icon) }"
174
+ >
175
+ <slot name="item-icon" :page="page">
176
+ <PluginIcon
177
+ v-if="isPluginIconFormat(page.icon)"
178
+ class="mint-page-selector__metadata-icon"
179
+ :icon="page.icon"
180
+ size="sm"
181
+ variant="solid"
182
+ />
183
+ <span v-else class="mint-page-selector__icon-fallback">{{ page.label.charAt(0) }}</span>
184
+ </slot>
185
+ </span>
186
+ <span class="mint-page-selector__item-text">
187
+ <span class="mint-page-selector__item-label">{{ page.label }}</span>
188
+ <span v-if="page.hint" class="mint-page-selector__item-hint">{{ page.hint }}</span>
189
+ </span>
190
+ <span v-if="index < 9" class="mint-page-selector__item-key" aria-hidden="true">{{ index + 1 }}</span>
191
+ </ActionItemInternal>
192
+ </div>
125
193
  </div>
126
194
  </div>
127
195
  </template>
@@ -1,9 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  /** DatePicker type="date": input that opens a calendar dropdown for selecting a single date, with clearable support. */
3
- import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
4
- import { ref, computed, nextTick, onBeforeUnmount, watch, useTemplateRef } from 'vue'
3
+ import { ref, computed, nextTick, watch, useTemplateRef } from 'vue'
5
4
  import { useCalendarGrid, type CalendarGridDay } from '../../composables/useCalendarGrid'
6
5
  import { useDropdownState } from '../../composables/useDropdownState'
6
+ import { useFloatingPanel } from '../../composables/useFloatingPanel'
7
7
  import { useFocusTrap } from '../../composables/useFocusTrap'
8
8
  import CalendarGridPanelInternal from './CalendarGridPanelInternal.vue'
9
9
 
@@ -30,11 +30,13 @@ const emit = defineEmits<{
30
30
  }>()
31
31
 
32
32
  const dropdownRef = ref<HTMLElement | null>(null)
33
- const dropdownStyle = ref<Record<string, string>>({})
34
33
  const { isOpen, triggerRef, open, close, toggle } = useDropdownState({
35
34
  rootRef: useTemplateRef<HTMLElement>('container'),
36
35
  insideRefs: [dropdownRef],
37
36
  })
37
+ // floating-ui keeps the panel anchored (flip/shift) while the page scrolls,
38
+ // instead of the panel closing on every scroll or resize.
39
+ const { style: dropdownStyle } = useFloatingPanel(triggerRef, dropdownRef, isOpen)
38
40
  // The panel is a non-modal dialog: Tab cycles inside it while it is open, and
39
41
  // focus returns to the input when it closes.
40
42
  useFocusTrap({ containerRef: dropdownRef, active: isOpen })
@@ -83,41 +85,6 @@ function handleTriggerKeydown(event: KeyboardEvent) {
83
85
  }
84
86
  }
85
87
 
86
- // floating-ui keeps the panel anchored (flip/shift) while the page scrolls,
87
- // instead of the panel closing on every scroll or resize.
88
- let stopAutoUpdate: (() => void) | null = null
89
-
90
- async function updateDropdownPosition() {
91
- const trigger = triggerRef.value
92
- const panel = dropdownRef.value
93
- if (!trigger || !panel) return
94
-
95
- const { x, y } = await computePosition(trigger, panel, {
96
- placement: 'bottom-start',
97
- strategy: 'fixed',
98
- middleware: [offset(4), flip(), shift({ padding: 8 })],
99
- })
100
- dropdownStyle.value = {
101
- position: 'fixed',
102
- top: `${y}px`,
103
- left: `${x}px`,
104
- zIndex: '9999',
105
- }
106
- }
107
-
108
- function stopPositioning() {
109
- stopAutoUpdate?.()
110
- stopAutoUpdate = null
111
- }
112
-
113
- function startPositioning() {
114
- const trigger = triggerRef.value
115
- const panel = dropdownRef.value
116
- if (!trigger || !panel) return
117
- stopPositioning()
118
- stopAutoUpdate = autoUpdate(trigger, panel, () => void updateDropdownPosition())
119
- }
120
-
121
88
  /** Moves focus into the calendar grid: selected day, else today, else the first day. */
122
89
  function focusCalendar() {
123
90
  const panel = dropdownRef.value
@@ -130,21 +97,15 @@ function focusCalendar() {
130
97
  }
131
98
 
132
99
  watch(isOpen, async (opened) => {
133
- if (!opened) {
134
- stopPositioning()
135
- return
136
- }
100
+ if (!opened) return
137
101
  if (selectedDate.value) {
138
102
  setCurrentMonthFromDate(selectedDate.value)
139
103
  }
140
104
  await nextTick()
141
- startPositioning()
142
105
  // A second tick so this focus lands after the focus trap's initial focus.
143
106
  await nextTick()
144
107
  focusCalendar()
145
108
  })
146
-
147
- onBeforeUnmount(stopPositioning)
148
109
  </script>
149
110
 
150
111
  <template>
@@ -202,6 +163,7 @@ onBeforeUnmount(stopPositioning)
202
163
  :style="dropdownStyle"
203
164
  role="dialog"
204
165
  aria-label="Date picker"
166
+ @keydown.escape.prevent="close(true)"
205
167
  >
206
168
  <CalendarGridPanelInternal
207
169
  :week-days="weekDays"
@@ -3,6 +3,7 @@
3
3
  import { computed, ref, watch, nextTick, useTemplateRef } from 'vue'
4
4
  import { useCalendarGrid, type CalendarGridDay } from '../../composables/useCalendarGrid'
5
5
  import { useDropdownState } from '../../composables/useDropdownState'
6
+ import { useFloatingPanel } from '../../composables/useFloatingPanel'
6
7
  import { useFocusTrap } from '../../composables/useFocusTrap'
7
8
  import { formatTime, formatTimeSlot, generateTimeSlots, toMinutes } from '../../utils/time'
8
9
  import CalendarGridPanelInternal from './CalendarGridPanelInternal.vue'
@@ -37,10 +38,13 @@ const emit = defineEmits<{
37
38
  }>()
38
39
 
39
40
  const dropdownRef = ref<HTMLElement | null>(null)
41
+ const rootRef = useTemplateRef<HTMLElement>('root')
40
42
  const { isOpen, open, close, toggle } = useDropdownState({
41
- rootRef: useTemplateRef<HTMLElement>('root'),
43
+ rootRef,
42
44
  triggerRef: useTemplateRef<HTMLElement>('trigger'),
43
45
  })
46
+ // Fixed below the field, so a modal body's overflow does not clip it; it stays in place in the DOM.
47
+ const { style: dropdownStyle } = useFloatingPanel(rootRef, dropdownRef, isOpen)
44
48
  // Non-modal dialog: Tab stays inside the panel, focus returns to the input on close.
45
49
  useFocusTrap({ containerRef: dropdownRef, active: isOpen })
46
50
  const {
@@ -230,8 +234,10 @@ watch(isOpen, async (opened) => {
230
234
  v-if="isOpen"
231
235
  ref="dropdownRef"
232
236
  class="mint-popover mint-datetime-picker__dropdown"
237
+ :style="dropdownStyle"
233
238
  role="dialog"
234
239
  aria-label="Date and time picker"
240
+ @keydown.escape.prevent="close(true)"
235
241
  >
236
242
  <!-- Calendar section -->
237
243
  <div class="mint-datetime-picker__calendar-section">
@@ -3,6 +3,7 @@
3
3
  import { ref, computed, watch, nextTick, useId, useTemplateRef } from 'vue'
4
4
  import type { TimeSlotDisabledFn } from '../../types'
5
5
  import { useDropdownState } from '../../composables/useDropdownState'
6
+ import { useFloatingPanel } from '../../composables/useFloatingPanel'
6
7
  import {
7
8
  compareTime,
8
9
  findNearestTimeSlotIndex,
@@ -40,12 +41,16 @@ const emit = defineEmits<{
40
41
  }>()
41
42
 
42
43
  const listRef = ref<HTMLUListElement | null>(null)
44
+ const dropdownRef = ref<HTMLElement | null>(null)
43
45
  const highlightedIndex = ref(-1)
44
46
  const listboxId = `mint-time-picker-listbox-${useId().replace(/:/g, '')}`
47
+ const rootRef = useTemplateRef<HTMLElement>('root')
45
48
  const { isOpen, open, close, toggle } = useDropdownState({
46
- rootRef: useTemplateRef<HTMLElement>('root'),
49
+ rootRef,
47
50
  triggerRef: useTemplateRef<HTMLElement>('trigger'),
48
51
  })
52
+ // Fixed below the field and at least as wide, so a modal body's overflow does not clip it; it stays in place in the DOM.
53
+ const { style: dropdownStyle } = useFloatingPanel(rootRef, dropdownRef, isOpen, { matchWidth: true })
49
54
 
50
55
  /** Id of the highlighted slot, published to the input as `aria-activedescendant`. */
51
56
  const activeSlotId = computed(() =>
@@ -258,7 +263,9 @@ watch(isOpen, (open) => {
258
263
  >
259
264
  <div
260
265
  v-if="isOpen"
266
+ ref="dropdownRef"
261
267
  class="mint-popover mint-time-picker__dropdown"
268
+ :style="dropdownStyle"
262
269
  >
263
270
  <ul
264
271
  :id="listboxId"
@@ -4,9 +4,9 @@
4
4
  * previews the value in each unit; units with a `factor` also get a hint in the neighbour unit
5
5
  * nearest to 1–1000.
6
6
  */
7
- import { computePosition, flip, offset, shift } from '@floating-ui/dom'
8
- import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
7
+ import { computed, nextTick, ref, useTemplateRef } from 'vue'
9
8
  import { useDropdownState } from '../../composables/useDropdownState'
9
+ import { useFloatingPanel } from '../../composables/useFloatingPanel'
10
10
  import { useMenuKeyboard } from '../../composables/useMenuKeyboard'
11
11
  import type { UnitOption } from '../../types'
12
12
 
@@ -148,27 +148,13 @@ function selectUnit(newUnitValue: string) {
148
148
 
149
149
  // ---- Unit menu (disclosure + WAI-ARIA keyboard, shared with ActionMenu) ----
150
150
  const menuRef = ref<HTMLElement | null>(null)
151
- // Transparent (not visibility:hidden) until placed, so the open-time focus can land on an option.
152
- const menuStyle = ref<Record<string, string>>({ opacity: '0' })
153
151
  const { isOpen, rootRef, open, close } = useDropdownState({
154
152
  triggerRef: useTemplateRef<HTMLElement>('trigger'),
155
- closeOnScroll: true,
156
153
  insideRefs: [menuRef],
157
- onOpen: () => { menuStyle.value = { opacity: '0' } },
158
154
  })
159
155
 
160
- /** Teleported + fixed like ActionMenu, so overflow:hidden/auto ancestors never clip the menu. */
161
- watch(isOpen, async (opened) => {
162
- if (!opened) return
163
- await nextTick()
164
- if (!rootRef.value || !menuRef.value) return
165
- const { x, y } = await computePosition(rootRef.value, menuRef.value, {
166
- placement: 'bottom-end',
167
- strategy: 'fixed',
168
- middleware: [offset(4), flip(), shift({ padding: 8 })],
169
- })
170
- menuStyle.value = { left: `${x}px`, top: `${y}px` }
171
- })
156
+ /** Teleported + fixed like ActionMenu, so overflow:hidden/auto ancestors never clip the menu; it follows the field on scroll. */
157
+ const { style: menuStyle } = useFloatingPanel(rootRef, menuRef, isOpen, { placement: 'bottom-end' })
172
158
 
173
159
  function menuItems(): HTMLElement[] {
174
160
  return menuRef.value ? Array.from(menuRef.value.querySelectorAll<HTMLElement>('[role="option"]')) : []
@@ -0,0 +1,173 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The tour layer: a scrim with a hole over the anchor, the step card, and the
4
+ * what's-new dots. AppTopBar renders it when the app provides onboarding.
5
+ */
6
+ import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
7
+ import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
8
+ import { X } from 'lucide-vue-next'
9
+ import BaseButton from '../BaseButton.vue'
10
+ import { useEventListener } from '../../composables/useEventListener'
11
+ import type { OnboardingState } from '../../composables/useOnboarding'
12
+
13
+ const props = defineProps<{ state: OnboardingState }>()
14
+
15
+ // Space between the anchor and the edge of the hole.
16
+ const HOLE_PAD = 4
17
+
18
+ const active = computed(() => props.state.active.value)
19
+ const card = ref<HTMLElement | null>(null)
20
+ const hole = ref<DOMRect | null>(null)
21
+ const cardPosition = ref({ x: 0, y: 0 })
22
+ const titleId = useId()
23
+ let stopAutoUpdate: (() => void) | undefined
24
+
25
+ async function position() {
26
+ const current = active.value
27
+ if (!current || !card.value) return
28
+ hole.value = current.element.getBoundingClientRect()
29
+ const { x, y } = await computePosition(current.element, card.value, {
30
+ strategy: 'fixed',
31
+ placement: current.step.placement ?? 'bottom',
32
+ middleware: [offset(12), flip(), shift({ padding: 12 })],
33
+ })
34
+ // The step may have changed while the position was computed.
35
+ if (active.value === current) cardPosition.value = { x, y }
36
+ }
37
+
38
+ // Focus goes back where it was when the run ends.
39
+ let returnFocus: HTMLElement | null = null
40
+ watch(active, async (current, previous) => {
41
+ stopAutoUpdate?.()
42
+ stopAutoUpdate = undefined
43
+ hole.value = null
44
+ if (current && !previous) returnFocus = document.activeElement as HTMLElement | null
45
+ if (!current) {
46
+ if (returnFocus?.isConnected) returnFocus.focus()
47
+ returnFocus = null
48
+ return
49
+ }
50
+ await nextTick()
51
+ if (!card.value || active.value !== current) return
52
+ current.element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
53
+ stopAutoUpdate = autoUpdate(current.element, card.value, () => void position())
54
+ // A click step: the keyboard user presses the control itself.
55
+ if (current.step.advanceOn === 'click') current.element.focus()
56
+ // Otherwise the card itself: no focus ring on load, and Tab reaches Back and Next.
57
+ else card.value.focus()
58
+ }, { immediate: true })
59
+ onBeforeUnmount(() => stopAutoUpdate?.())
60
+
61
+ // The layer is its own focus trap: Tab cycles over the card's buttons (and the
62
+ // anchor on a click step), never the page behind; from the card itself Tab goes
63
+ // to the first stop and Shift+Tab to the last.
64
+ useEventListener(document, 'keydown', (event) => {
65
+ const current = active.value
66
+ if (!current || !card.value) return
67
+ // Keys belong to the tour only while focus is on its card, its anchor, or nowhere.
68
+ const focused = document.activeElement
69
+ if (focused && focused !== document.body && !card.value.contains(focused) && focused !== current.element) return
70
+ const onCard = focused === card.value
71
+ if (event.key === 'Escape') {
72
+ event.stopPropagation()
73
+ props.state.dismiss()
74
+ } else if (event.key === 'Tab') {
75
+ const buttons = [...card.value.querySelectorAll<HTMLElement>('button')]
76
+ const stops = current.step.advanceOn === 'click' ? [current.element, ...buttons] : buttons
77
+ const at = stops.indexOf(document.activeElement as HTMLElement)
78
+ const target = at === -1 ? (event.shiftKey ? stops.length - 1 : 0) : (at + (event.shiftKey ? -1 : 1) + stops.length) % stops.length
79
+ event.preventDefault()
80
+ stops[target].focus()
81
+ } else if (event.key === 'Enter' && onCard && current.step.advanceOn !== 'click') {
82
+ event.preventDefault()
83
+ props.state.next()
84
+ }
85
+ }, { capture: true })
86
+
87
+ const holeStyle = computed(() => hole.value && {
88
+ left: `${hole.value.left - HOLE_PAD}px`,
89
+ top: `${hole.value.top - HOLE_PAD}px`,
90
+ width: `${hole.value.width + 2 * HOLE_PAD}px`,
91
+ height: `${hole.value.height + 2 * HOLE_PAD}px`,
92
+ })
93
+ // advanceOn 'click': cut the hole out of the click blocker, so the anchor takes the click.
94
+ const blockerStyle = computed(() => {
95
+ const rect = hole.value
96
+ if (!rect || active.value?.step.advanceOn !== 'click') return undefined
97
+ const [x, y, w, h] = [rect.left - HOLE_PAD, rect.top - HOLE_PAD, rect.width + 2 * HOLE_PAD, rect.height + 2 * HOLE_PAD]
98
+ return { clipPath: `path(evenodd, 'M0 0H${window.innerWidth}V${window.innerHeight}H0Z M${x} ${y}h${w}v${h}h${-w}Z')` }
99
+ })
100
+ const isLast = computed(() => !!active.value && active.value.index === active.value.count - 1)
101
+ const nextLabel = computed(() => active.value?.news ? 'Got it' : isLast.value ? 'Done' : 'Next')
102
+
103
+ // What's-new dots: one on each element with unseen news, hidden while a card shows.
104
+ const dots = ref<Array<{ id: string; title: string; x: number; y: number }>>([])
105
+ let frame = 0
106
+ function placeDots() {
107
+ cancelAnimationFrame(frame)
108
+ frame = requestAnimationFrame(() => {
109
+ dots.value = active.value ? [] : props.state.news.value.flatMap((item) => {
110
+ if (!item.unseen) return []
111
+ const element = document.querySelector(`[data-onboarding="${CSS.escape(item.anchor)}"]`)
112
+ if (!element) return []
113
+ const rect = element.getBoundingClientRect()
114
+ return rect.width ? [{ id: item.id, title: item.title, x: rect.right, y: rect.top }] : []
115
+ })
116
+ })
117
+ }
118
+ const observer = new MutationObserver(placeDots)
119
+ watch(() => props.state.unseenNews.value > 0, (watching) => {
120
+ observer.disconnect()
121
+ if (watching) observer.observe(document.body, { childList: true, subtree: true })
122
+ placeDots()
123
+ }, { immediate: true })
124
+ watch(active, placeDots)
125
+ useEventListener(window, 'resize', placeDots)
126
+ useEventListener(window, 'scroll', placeDots, { capture: true, passive: true })
127
+ onBeforeUnmount(() => {
128
+ observer.disconnect()
129
+ cancelAnimationFrame(frame)
130
+ })
131
+ </script>
132
+
133
+ <template>
134
+ <Teleport to="body">
135
+ <button
136
+ v-for="dot in dots"
137
+ :key="dot.id"
138
+ type="button"
139
+ class="mint-onboarding__dot"
140
+ :style="{ left: `${dot.x}px`, top: `${dot.y}px` }"
141
+ :aria-label="`New: ${dot.title}`"
142
+ @click="state.showNews(dot.id)"
143
+ />
144
+ <div v-if="active" class="mint-onboarding">
145
+ <div class="mint-onboarding__blocker" :style="blockerStyle" />
146
+ <div v-if="holeStyle" class="mint-onboarding__hole" :style="holeStyle" />
147
+ <div
148
+ ref="card"
149
+ class="mint-onboarding__card"
150
+ role="dialog"
151
+ aria-modal="true"
152
+ :aria-labelledby="titleId"
153
+ tabindex="-1"
154
+ :style="{ left: `${cardPosition.x}px`, top: `${cardPosition.y}px` }"
155
+ >
156
+ <button type="button" class="mint-onboarding__close" :aria-label="active.news ? 'Close' : 'Close tour'" @click="state.dismiss()">
157
+ <X :size="14" aria-hidden="true" />
158
+ </button>
159
+ <span v-if="active.count > 1" class="mint-onboarding__count">
160
+ <span aria-hidden="true">{{ active.index + 1 }} / {{ active.count }}</span>
161
+ <span class="mint-onboarding__sr">Step {{ active.index + 1 }} of {{ active.count }}</span>
162
+ </span>
163
+ <p :id="titleId" class="mint-onboarding__title">{{ active.step.title }}</p>
164
+ <p class="mint-onboarding__body">{{ active.step.body }}</p>
165
+ <div class="mint-onboarding__footer">
166
+ <span v-if="active.step.advanceOn === 'click'" class="mint-onboarding__hint">Click the highlighted control.</span>
167
+ <BaseButton v-if="active.index > 0" variant="ghost" size="sm" @click="state.back()">Back</BaseButton>
168
+ <BaseButton v-if="active.step.advanceOn !== 'click'" size="sm" @click="state.next()">{{ nextLabel }}</BaseButton>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ </Teleport>
173
+ </template>