@morscherlab/mint-sdk 1.4.0-beta.12 → 1.4.0-beta.14
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.
- package/dist/{AlertBox-BdoZqANv.js → AlertBox-SnsbVow-.js} +3 -3
- package/dist/{AlertBox-BdoZqANv.js.map → AlertBox-SnsbVow-.js.map} +1 -1
- package/dist/{AppSidebar-DGK5i-hJ.js → AppSidebar-Bi8g9mRt.js} +237 -70
- package/dist/{AppSidebar-DGK5i-hJ.js.map → AppSidebar-Bi8g9mRt.js.map} +1 -1
- package/dist/{ConfirmDialog-nbJj51is.js → ConfirmDialog-CSls2Nuv.js} +2 -2
- package/dist/{ConfirmDialog-nbJj51is.js.map → ConfirmDialog-CSls2Nuv.js.map} +1 -1
- package/dist/{ExperimentPopover-CbD7BR_s.js → ExperimentPopover-ChKCrklH.js} +5 -5
- package/dist/{ExperimentPopover-CbD7BR_s.js.map → ExperimentPopover-ChKCrklH.js.map} +1 -1
- package/dist/{ExperimentSelectorModal-BoofZZxF.js → ExperimentSelectorModal-BqifjNaN.js} +113 -97
- package/dist/ExperimentSelectorModal-BqifjNaN.js.map +1 -0
- package/dist/{SegmentedControl-BkwYOKpG.js → SegmentedControl-hOgBVdju.js} +2 -2
- package/dist/{SegmentedControl-BkwYOKpG.js.map → SegmentedControl-hOgBVdju.js.map} +1 -1
- package/dist/{SettingsModal-BXsjIVnm.js → SettingsModal-jra_2u0o.js} +7 -7
- package/dist/{SettingsModal-BXsjIVnm.js.map → SettingsModal-jra_2u0o.js.map} +1 -1
- package/dist/__stories__/experiment-helpers.d.ts +10 -0
- package/dist/components/AppTopBar.vue.d.ts +13 -5
- package/dist/components/BaseButton.vue.d.ts +4 -2
- package/dist/components/ExperimentSelectorModal.vue.d.ts +15 -1
- package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -1
- package/dist/{controlSchemaModel-BF5g4wRC.js → controlSchemaModel-CIH5Hqpw.js} +2 -2
- package/dist/{controlSchemaModel-BF5g4wRC.js.map → controlSchemaModel-CIH5Hqpw.js.map} +1 -1
- package/dist/{data-ihsBd9bs.js → data-OZ94DhVC.js} +11 -11
- package/dist/{data-ihsBd9bs.js.map → data-OZ94DhVC.js.map} +1 -1
- package/dist/data.js +8 -8
- package/dist/{experimentStatus-Cqp6qNrR.js → experimentStatus-BFrKpHAH.js} +2 -2
- package/dist/{experimentStatus-Cqp6qNrR.js.map → experimentStatus-BFrKpHAH.js.map} +1 -1
- package/dist/index.js +18 -18
- package/dist/{install-CFgfRO64.js → install-Bx_6KwJA.js} +15 -15
- package/dist/{install-CFgfRO64.js.map → install-Bx_6KwJA.js.map} +1 -1
- package/dist/install.js +1 -1
- package/dist/{lab-B-pLwc5U.js → lab-ZDKlx1Oo.js} +10 -10
- package/dist/{lab-B-pLwc5U.js.map → lab-ZDKlx1Oo.js.map} +1 -1
- package/dist/lab.js +4 -4
- package/dist/{records-DnK_1Q_S.js → records-B4yl2oIu.js} +2 -2
- package/dist/{records-DnK_1Q_S.js.map → records-B4yl2oIu.js.map} +1 -1
- package/dist/{runtime-pKfF_iql.js → runtime-BJvGms6h.js} +9 -9
- package/dist/{runtime-pKfF_iql.js.map → runtime-BJvGms6h.js.map} +1 -1
- package/dist/runtime.js +6 -6
- package/dist/{shell-BIH8Rc21.js → shell-Czyrzk7l.js} +5 -5
- package/dist/{shell-BIH8Rc21.js.map → shell-Czyrzk7l.js.map} +1 -1
- package/dist/shell.js +5 -5
- package/dist/styles.css +296 -45
- package/dist/types/index.d.ts +1 -1
- package/dist/types/jobs.d.ts +9 -0
- package/dist/{useDropdownState-8yWMu-7_.js → useDropdownState-Bb2N4m40.js} +15 -3
- package/dist/useDropdownState-Bb2N4m40.js.map +1 -0
- package/dist/{useExperimentSamples-BGN1dmcR.js → useExperimentSamples-BLx29CkM.js} +3 -3
- package/dist/{useExperimentSamples-BGN1dmcR.js.map → useExperimentSamples-BLx29CkM.js.map} +1 -1
- package/dist/{useListReorder-DPynGrCj.js → useListReorder-C_8RYeVP.js} +3 -3
- package/dist/{useListReorder-DPynGrCj.js.map → useListReorder-C_8RYeVP.js.map} +1 -1
- package/dist/{useRackEditor-B2FrQA0p.js → useRackEditor-BhYlXKFM.js} +8 -8
- package/dist/{useRackEditor-B2FrQA0p.js.map → useRackEditor-BhYlXKFM.js.map} +1 -1
- package/dist/{useRealtime-yWjTTnc9.js → useRealtime-CUMbW_ln.js} +2 -2
- package/dist/{useRealtime-yWjTTnc9.js.map → useRealtime-CUMbW_ln.js.map} +1 -1
- package/dist/{useRequest-Dqw12k2s.js → useRequest-C9uwM79q.js} +3 -3
- package/dist/{useRequest-Dqw12k2s.js.map → useRequest-C9uwM79q.js.map} +1 -1
- package/package.json +1 -1
- package/src/__stories__/experiment-helpers.ts +3 -3
- package/src/__tests__/components/AppTopBar.test.ts +175 -1
- package/src/__tests__/components/AppTopBarPageSelector.test.ts +125 -9
- package/src/__tests__/components/BaseButton.test.ts +19 -0
- package/src/__tests__/components/ExperimentSelectorModal.test.ts +34 -1
- package/src/__tests__/styles/menusShellRefresh.test.ts +8 -2
- package/src/components/AppTopBar.story.vue +59 -4
- package/src/components/AppTopBar.vue +107 -8
- package/src/components/BaseButton.vue +16 -2
- package/src/components/ExperimentSelectorModal.story.vue +38 -0
- package/src/components/ExperimentSelectorModal.vue +20 -1
- package/src/components/internal/AppTopBarPageSelectorInternal.vue +140 -72
- package/src/styles/components/app-page-selector.css +120 -45
- package/src/styles/components/app-top-bar.css +155 -0
- package/src/styles/components/button.css +8 -0
- package/src/styles/components/experiment-selector-modal.css +13 -0
- package/src/types/index.ts +1 -0
- package/src/types/jobs.ts +10 -0
- package/dist/ExperimentSelectorModal-BoofZZxF.js.map +0 -1
- package/dist/useDropdownState-8yWMu-7_.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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* <BaseButton variant="ghost" :icon="Trash2" label="Delete" />
|
|
11
11
|
* ```
|
|
12
12
|
*/
|
|
13
|
-
import { computed, useSlots, type Component } from 'vue'
|
|
13
|
+
import { Comment, Fragment, Text, computed, useSlots, type Component, type VNode } from 'vue'
|
|
14
14
|
import type { ButtonVariant, ButtonSize } from '../types'
|
|
15
15
|
|
|
16
16
|
interface Props {
|
|
@@ -45,6 +45,19 @@ const slots = useSlots()
|
|
|
45
45
|
const iconOnly = computed(() => Boolean(props.icon) && !slots.default)
|
|
46
46
|
const block = computed(() => (iconOnly.value ? 'mint-icon-button' : 'mint-button'))
|
|
47
47
|
|
|
48
|
+
// A text-only label gets its own span so the stylesheet can centre the cap height.
|
|
49
|
+
// Element children are left as flex items, so their gap and alignment do not change.
|
|
50
|
+
function isTextNode(node: VNode): boolean {
|
|
51
|
+
if (node.type === Text || node.type === Comment) return true
|
|
52
|
+
return node.type === Fragment && Array.isArray(node.children)
|
|
53
|
+
&& (node.children as VNode[]).every(isTextNode)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hasTextLabel(): boolean {
|
|
57
|
+
const nodes = slots.default?.()
|
|
58
|
+
return !!nodes && nodes.length > 0 && nodes.every(isTextNode)
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
function handleClick(event: MouseEvent) {
|
|
49
62
|
if (!props.disabled && !props.loading) {
|
|
50
63
|
emit('click', event)
|
|
@@ -93,7 +106,8 @@ function handleClick(event: MouseEvent) {
|
|
|
93
106
|
<template v-if="!iconOnly">
|
|
94
107
|
<component :is="icon" v-if="icon" class="mint-button__icon" aria-hidden="true" />
|
|
95
108
|
<slot name="icon-left" />
|
|
96
|
-
<slot
|
|
109
|
+
<span v-if="hasTextLabel()" class="mint-button__label"><slot /></span>
|
|
110
|
+
<slot v-else />
|
|
97
111
|
<slot name="icon-right" />
|
|
98
112
|
</template>
|
|
99
113
|
</button>
|
|
@@ -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
|
-
/**
|
|
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
|
|
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:
|
|
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-
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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="
|
|
118
|
+
<slot name="icon" :page="current">
|
|
113
119
|
<PluginIcon
|
|
114
|
-
v-if="isPluginIconFormat(
|
|
115
|
-
class="mint-page-
|
|
116
|
-
: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">{{
|
|
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-
|
|
124
|
-
|
|
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>
|