@morscherlab/mint-sdk 1.4.0-beta.12 → 1.4.0-beta.13

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 (43) hide show
  1. package/dist/{AppSidebar-DGK5i-hJ.js → AppSidebar-CIXJBzDv.js} +234 -67
  2. package/dist/{AppSidebar-DGK5i-hJ.js.map → AppSidebar-CIXJBzDv.js.map} +1 -1
  3. package/dist/{ExperimentSelectorModal-BoofZZxF.js → ExperimentSelectorModal-BbFtr1Fr.js} +109 -93
  4. package/dist/ExperimentSelectorModal-BbFtr1Fr.js.map +1 -0
  5. package/dist/{SettingsModal-BXsjIVnm.js → SettingsModal-CdhFMi8j.js} +2 -2
  6. package/dist/{SettingsModal-BXsjIVnm.js.map → SettingsModal-CdhFMi8j.js.map} +1 -1
  7. package/dist/__stories__/experiment-helpers.d.ts +10 -0
  8. package/dist/components/AppTopBar.vue.d.ts +13 -5
  9. package/dist/components/ExperimentSelectorModal.vue.d.ts +15 -1
  10. package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -1
  11. package/dist/{data-ihsBd9bs.js → data-DUt0TIp5.js} +2 -2
  12. package/dist/{data-ihsBd9bs.js.map → data-DUt0TIp5.js.map} +1 -1
  13. package/dist/data.js +2 -2
  14. package/dist/index.js +7 -7
  15. package/dist/{install-CFgfRO64.js → install-Czoe1rA8.js} +6 -6
  16. package/dist/{install-CFgfRO64.js.map → install-Czoe1rA8.js.map} +1 -1
  17. package/dist/install.js +1 -1
  18. package/dist/{runtime-pKfF_iql.js → runtime-fPb_FEZr.js} +2 -2
  19. package/dist/{runtime-pKfF_iql.js.map → runtime-fPb_FEZr.js.map} +1 -1
  20. package/dist/runtime.js +2 -2
  21. package/dist/{shell-BIH8Rc21.js → shell-lDf_EY-x.js} +2 -2
  22. package/dist/{shell-BIH8Rc21.js.map → shell-lDf_EY-x.js.map} +1 -1
  23. package/dist/shell.js +2 -2
  24. package/dist/styles.css +288 -45
  25. package/dist/types/index.d.ts +1 -1
  26. package/dist/types/jobs.d.ts +9 -0
  27. package/package.json +1 -1
  28. package/src/__stories__/experiment-helpers.ts +3 -3
  29. package/src/__tests__/components/AppTopBar.test.ts +175 -1
  30. package/src/__tests__/components/AppTopBarPageSelector.test.ts +125 -9
  31. package/src/__tests__/components/ExperimentSelectorModal.test.ts +34 -1
  32. package/src/__tests__/styles/menusShellRefresh.test.ts +8 -2
  33. package/src/components/AppTopBar.story.vue +59 -4
  34. package/src/components/AppTopBar.vue +107 -8
  35. package/src/components/ExperimentSelectorModal.story.vue +38 -0
  36. package/src/components/ExperimentSelectorModal.vue +20 -1
  37. package/src/components/internal/AppTopBarPageSelectorInternal.vue +140 -72
  38. package/src/styles/components/app-page-selector.css +120 -45
  39. package/src/styles/components/app-top-bar.css +155 -0
  40. package/src/styles/components/experiment-selector-modal.css +13 -0
  41. package/src/types/index.ts +1 -0
  42. package/src/types/jobs.ts +10 -0
  43. package/dist/ExperimentSelectorModal-BoofZZxF.js.map +0 -1
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * The `#icon` slot applies in standalone mode only; integrated branding comes from `Metadata.icon`.
6
6
  */
7
- import { ref, computed, inject, defineAsyncComponent, onMounted, onBeforeUnmount, toValue, useSlots, watch } from 'vue'
7
+ import { ref, computed, inject, defineAsyncComponent, onMounted, onBeforeUnmount, toValue, useSlots, watch, type Component } from 'vue'
8
8
  import type {
9
9
  PillNavOption,
10
10
  PillNavItem,
@@ -25,6 +25,9 @@ import { APP_EXPERIMENT_KEY } from '../composables/useAppExperiment'
25
25
  import { pluginIdentity } from '../composables/pluginIdentity'
26
26
  import { integrationHost } from '../utils/integrationHost'
27
27
  import { trackTopBarForToasts } from './AppTopBar.toastOffset'
28
+ import { CircleAlert, CircleCheck, CircleX, ListChecks, LoaderCircle } from 'lucide-vue-next'
29
+ import type { JobStatus, TopBarJob } from '../types/jobs'
30
+ import type { Tone } from '../types/components'
28
31
 
29
32
  interface Props {
30
33
  /** App or plugin title shown in the left title group when no page selector is present. */
@@ -39,6 +42,11 @@ interface Props {
39
42
  pageSelector?: PageSelectorItemInput[]
40
43
  /** Active id of the platform page selector. */
41
44
  currentPageSelectorId?: string
45
+ /**
46
+ * The viewer's own job, joined to the experiment chip. Omit it for no jobs control;
47
+ * `null` shows the idle Jobs button. Both emit `job-click`.
48
+ */
49
+ job?: TopBarJob | null
42
50
  }
43
51
 
44
52
  const props = withDefaults(defineProps<Props>(), {
@@ -66,6 +74,7 @@ const emit = defineEmits<{
66
74
  'pill-select': [item: PillNavItem]
67
75
  'pill-option-select': [option: PillNavOption, item: PillNavItem]
68
76
  'settings-values-change': [data: Record<string, unknown>]
77
+ 'job-click': []
69
78
  }>()
70
79
 
71
80
  const settingsOpen = ref(false)
@@ -79,6 +88,44 @@ const { integrated: isIntegrated, plugin } = usePlatform()
79
88
  const isStandalone = computed(() => !isIntegrated.value)
80
89
  const appExperiment = inject(APP_EXPERIMENT_KEY, null)
81
90
  const canBindExperiment = computed(() => plugin.value?.plugin_type !== 'static')
91
+ const showExperiment = computed(() => !!appExperiment && !isStandalone.value && canBindExperiment.value)
92
+ // The border joins only once the job is on screen and stays until it has left,
93
+ // so the name and the job never show at the same time.
94
+ const runSlotJoined = ref(!!props.job)
95
+ const JOB_STATUS_TEXT: Record<JobStatus, string> = {
96
+ queued: 'Job queued',
97
+ running: 'Job running',
98
+ awaiting_input: 'Job needs input',
99
+ completed: 'Job completed',
100
+ failed: 'Job failed',
101
+ cancelled: 'Job cancelled',
102
+ }
103
+ const jobPercent = computed(() =>
104
+ props.job?.percent == null ? null : Math.min(100, Math.max(0, Math.round(props.job.percent))),
105
+ )
106
+ const jobName = computed(() => props.job
107
+ ? (jobPercent.value != null ? `${props.job.label} ${jobPercent.value}%` : props.job.label)
108
+ : undefined)
109
+ function syncRunSlotJoined() {
110
+ runSlotJoined.value = !!props.job
111
+ }
112
+
113
+ const JOB_TONES: Record<JobStatus, Tone> = {
114
+ queued: 'primary',
115
+ running: 'primary',
116
+ awaiting_input: 'warning',
117
+ completed: 'success',
118
+ failed: 'error',
119
+ cancelled: 'neutral',
120
+ }
121
+ const JOB_ICONS: Record<JobStatus, Component> = {
122
+ queued: LoaderCircle,
123
+ running: LoaderCircle,
124
+ awaiting_input: CircleAlert,
125
+ completed: CircleCheck,
126
+ failed: CircleX,
127
+ cancelled: CircleX,
128
+ }
82
129
 
83
130
  const slots = useSlots()
84
131
  // Standalone: the seeded identity (providePluginIdentity) stands in for a missing icon and title.
@@ -103,6 +150,10 @@ watch(platformPageSelectorEl, (element, _previous, onCleanup) => {
103
150
  const identityName = computed(() =>
104
151
  isIntegrated.value && !hasPageSelector.value ? plugin.value?.display_name || plugin.value?.name : undefined,
105
152
  )
153
+ /** The page selector crumb reads "<plugin> / <page>". */
154
+ const pageSelectorPluginName = computed(() =>
155
+ isIntegrated.value ? plugin.value?.display_name || plugin.value?.name : props.title || pluginIdentity.name,
156
+ )
106
157
  // Platforms before 1.4 send no subtitle; the description fills line 2.
107
158
  const identitySubtitle = computed(() => plugin.value?.subtitle || plugin.value?.description)
108
159
  const hasPillNav = computed(() => !!config.value.pills?.length)
@@ -249,6 +300,7 @@ const normalizedSettingsTabs = computed<SettingsTab[]>(() =>
249
300
  v-else-if="hasPropPageSelector"
250
301
  :pages="normalizedPageSelector"
251
302
  :current-page-id="currentPageSelectorId"
303
+ :plugin-name="pageSelectorPluginName"
252
304
  @select="emit('page-selector-select', $event)"
253
305
  >
254
306
  <template v-if="$slots['page-selector-icon']" #icon="slotProps">
@@ -290,13 +342,60 @@ const normalizedSettingsTabs = computed<SettingsTab[]>(() =>
290
342
  {{ config.standaloneLabel }}
291
343
  </span>
292
344
 
293
- <ExperimentPopover
294
- v-if="appExperiment && !isStandalone && canBindExperiment"
295
- v-bind="appExperiment.popover.value"
296
- @select="appExperiment.openModal()"
297
- @save="appExperiment.handleSave()"
298
- @detach="appExperiment.handleDetach()"
299
- />
345
+ <!-- Run slot: while a job shows, its progress takes the place of the experiment name and Save:
346
+ [ EXP-0142 ▾ | job ]. The idle Jobs button stands apart. -->
347
+ <div
348
+ v-if="showExperiment || job !== undefined || runSlotJoined"
349
+ :class="['mint-topbar__run-slot', { 'mint-topbar__run-slot--joined': runSlotJoined }]"
350
+ >
351
+ <ExperimentPopover
352
+ v-if="showExperiment && appExperiment"
353
+ v-bind="job ? { ...appExperiment.popover.value, showSave: false } : appExperiment.popover.value"
354
+ @select="appExperiment.openModal()"
355
+ @save="appExperiment.handleSave()"
356
+ @detach="appExperiment.handleDetach()"
357
+ />
358
+ <!-- Status words only, so a moving percent does not repeat. -->
359
+ <span class="mint-topbar__job-status" aria-live="polite">{{ job ? JOB_STATUS_TEXT[job.status] : '' }}</span>
360
+ <!-- appear: the slot can mount together with its first job; before-enter then joins it. -->
361
+ <Transition
362
+ name="mint-topbar-job"
363
+ mode="out-in"
364
+ appear
365
+ @before-enter="syncRunSlotJoined"
366
+ @after-leave="syncRunSlotJoined"
367
+ >
368
+ <button
369
+ v-if="job"
370
+ key="run"
371
+ type="button"
372
+ :class="['mint-topbar__job', `mint-topbar__job--${JOB_TONES[job.status]}`]"
373
+ :title="jobName"
374
+ :aria-label="jobName"
375
+ @click="emit('job-click')"
376
+ >
377
+ <component
378
+ :is="JOB_ICONS[job.status]"
379
+ :class="['mint-topbar__job-icon', { 'mint-topbar__job-icon--spin': job.status === 'running' || job.status === 'queued' }]"
380
+ :size="14"
381
+ aria-hidden="true"
382
+ />
383
+ <span v-if="jobPercent != null" class="mint-topbar__job-bar" aria-hidden="true">
384
+ <span :style="{ width: `${jobPercent}%` }" />
385
+ </span>
386
+ </button>
387
+ <button
388
+ v-else-if="job === null"
389
+ key="idle"
390
+ type="button"
391
+ class="mint-topbar__job mint-topbar__job--idle"
392
+ @click="emit('job-click')"
393
+ >
394
+ <ListChecks class="mint-topbar__job-icon" :size="14" aria-hidden="true" />
395
+ <span class="mint-topbar__job-label">Jobs</span>
396
+ </button>
397
+ </Transition>
398
+ </div>
300
399
 
301
400
  <slot name="actions" />
302
401
 
@@ -268,6 +268,7 @@ onMounted(async () => {
268
268
 
269
269
  // -- Story state --
270
270
  const isOpen = ref(false)
271
+ const isOpenRun = ref(false)
271
272
  const isOpenEmpty = ref(false)
272
273
  const isOpenWithSelection = ref(false)
273
274
  const isOpenFilters = ref(false)
@@ -363,6 +364,43 @@ const selectedExperiment = computed(() => experimentStore.current)
363
364
  </div>
364
365
  </Variant>
365
366
 
367
+ <Variant title="Run Destination (slots)">
368
+ <div style="padding: 2rem;">
369
+ <p style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8); margin: 0 0 1rem;">
370
+ A plugin asks where to save a run: the description slot explains why, and the footer slot adds Skip with close().
371
+ </p>
372
+ <button
373
+ style="padding: 0.5rem 1rem; background: var(--color-primary); color: var(--text-on-accent); border: none; border-radius: var(--radius-md); cursor: pointer; font-size: 0.875rem;"
374
+ @click="isOpenRun = true"
375
+ >
376
+ Run extraction
377
+ </button>
378
+
379
+ <ExperimentSelectorModal
380
+ v-model:open="isOpenRun"
381
+ title="Save the result to an experiment?"
382
+ >
383
+ <template #description>
384
+ Pick an experiment to save this run as a new artifact in it, next to the samples it belongs to.
385
+ </template>
386
+ <template #footer="{ close }">
387
+ <div style="display: flex; align-items: center; gap: 0.75rem;">
388
+ <span style="flex: 1; font-size: 0.75rem; color: var(--text-secondary);">
389
+ Skip: the result stays in the plugin. You can save it to an experiment later.
390
+ </span>
391
+ <button
392
+ type="button"
393
+ style="height: 2rem; padding: 0 0.75rem; border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-card); color: var(--text-primary); cursor: pointer;"
394
+ @click="close"
395
+ >
396
+ Skip
397
+ </button>
398
+ </div>
399
+ </template>
400
+ </ExperimentSelectorModal>
401
+ </div>
402
+ </Variant>
403
+
366
404
  <Variant title="Empty State">
367
405
  <div style="padding: 2rem;">
368
406
  <p style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8); margin: 0 0 1rem;">
@@ -40,6 +40,17 @@ const emit = defineEmits<{
40
40
  'update:open': [value: boolean]
41
41
  }>()
42
42
 
43
+ defineSlots<{
44
+ /** Text above the experiment list: why the caller asks for an experiment. */
45
+ description?: () => unknown
46
+ /** Extra footer content, before Clear selection. `close()` closes the modal without a selection. */
47
+ footer?: (props: { close: () => void }) => unknown
48
+ }>()
49
+
50
+ function close() {
51
+ emit('update:open', false)
52
+ }
53
+
43
54
  const experimentStore = useExperimentStore()
44
55
 
45
56
  const {
@@ -188,6 +199,10 @@ watch(
188
199
  :size="size"
189
200
  @update:open="emit('update:open', $event)"
190
201
  >
202
+ <div v-if="$slots.description" class="mint-experiment-selector__description">
203
+ <slot name="description" />
204
+ </div>
205
+
191
206
  <ResourcePickerListInternal
192
207
  :key="openCount"
193
208
  :search="filters.search ?? ''"
@@ -257,8 +272,12 @@ watch(
257
272
  </template>
258
273
  </ResourcePickerListInternal>
259
274
 
260
- <div v-if="currentExperimentId != null" class="mint-experiment-selector__footer">
275
+ <div v-if="$slots.footer || currentExperimentId != null" class="mint-experiment-selector__footer">
276
+ <div v-if="$slots.footer" class="mint-experiment-selector__footer-slot">
277
+ <slot name="footer" :close="close" />
278
+ </div>
261
279
  <button
280
+ v-if="currentExperimentId != null"
262
281
  type="button"
263
282
  class="mint-experiment-selector__clear-btn"
264
283
  @click="handleDeselect"
@@ -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>