@morscherlab/mint-sdk 1.4.0-beta.11 → 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.
- package/dist/{AlertBox-B2Bgr3ei.js → AlertBox-BdoZqANv.js} +2 -2
- package/dist/{AlertBox-B2Bgr3ei.js.map → AlertBox-BdoZqANv.js.map} +1 -1
- package/dist/{AppSidebar-mvm--GxN.js → AppSidebar-CIXJBzDv.js} +237 -70
- package/dist/{AppSidebar-mvm--GxN.js.map → AppSidebar-CIXJBzDv.js.map} +1 -1
- package/dist/{ExperimentPopover-5T1fAq2L.js → ExperimentPopover-CbD7BR_s.js} +2 -2
- package/dist/{ExperimentPopover-5T1fAq2L.js.map → ExperimentPopover-CbD7BR_s.js.map} +1 -1
- package/dist/{ExperimentSelectorModal-65peG4Up.js → ExperimentSelectorModal-BbFtr1Fr.js} +112 -96
- package/dist/ExperimentSelectorModal-BbFtr1Fr.js.map +1 -0
- package/dist/{SegmentedControl-BtnnjWzs.js → SegmentedControl-BkwYOKpG.js} +2 -2
- package/dist/{SegmentedControl-BtnnjWzs.js.map → SegmentedControl-BkwYOKpG.js.map} +1 -1
- package/dist/{SettingsModal-DO3IZ7qz.js → SettingsModal-CdhFMi8j.js} +25 -24
- package/dist/SettingsModal-CdhFMi8j.js.map +1 -0
- package/dist/__stories__/experiment-helpers.d.ts +10 -0
- package/dist/__tests__/styles/paletteTokens.test.d.ts +1 -0
- package/dist/components/AppTopBar.vue.d.ts +13 -5
- package/dist/components/ExperimentSelectorModal.vue.d.ts +15 -1
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +1 -1
- package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -1
- package/dist/{controlSchemaModel-B8CTXagP.js → controlSchemaModel-BF5g4wRC.js} +2 -2
- package/dist/{controlSchemaModel-B8CTXagP.js.map → controlSchemaModel-BF5g4wRC.js.map} +1 -1
- package/dist/{data-CgILxDLW.js → data-DUt0TIp5.js} +10 -10
- package/dist/{data-CgILxDLW.js.map → data-DUt0TIp5.js.map} +1 -1
- package/dist/data.js +7 -7
- package/dist/{experiment-43OJtH7R.js → experiment-DyO54FQB.js} +25 -61
- package/dist/experiment-DyO54FQB.js.map +1 -0
- package/dist/index.js +18 -18
- package/dist/{install-mB8-IyvO.js → install-Czoe1rA8.js} +13 -13
- package/dist/{install-mB8-IyvO.js.map → install-Czoe1rA8.js.map} +1 -1
- package/dist/install.js +1 -1
- package/dist/{lab-D5bIX426.js → lab-B-pLwc5U.js} +10 -10
- package/dist/{lab-D5bIX426.js.map → lab-B-pLwc5U.js.map} +1 -1
- package/dist/lab.js +4 -4
- package/dist/{records-B6gpM_3e.js → records-DnK_1Q_S.js} +2 -2
- package/dist/{records-B6gpM_3e.js.map → records-DnK_1Q_S.js.map} +1 -1
- package/dist/{runtime-D7vKpfbF.js → runtime-fPb_FEZr.js} +9 -9
- package/dist/{runtime-D7vKpfbF.js.map → runtime-fPb_FEZr.js.map} +1 -1
- package/dist/runtime.js +7 -7
- package/dist/{shell-CywENwBT.js → shell-lDf_EY-x.js} +4 -4
- package/dist/{shell-CywENwBT.js.map → shell-lDf_EY-x.js.map} +1 -1
- package/dist/shell.js +4 -4
- package/dist/stores/index.d.ts +1 -1
- package/dist/stores/index.js +2 -2
- package/dist/stores/settings.d.ts +4 -12
- package/dist/styles.css +409 -60
- package/dist/types/index.d.ts +1 -1
- package/dist/types/jobs.d.ts +9 -0
- package/dist/types/platform.d.ts +1 -1
- package/dist/{useExperimentSamples-DHnxKFAF.js → useExperimentSamples-BGN1dmcR.js} +4 -4
- package/dist/{useExperimentSamples-DHnxKFAF.js.map → useExperimentSamples-BGN1dmcR.js.map} +1 -1
- package/dist/{useListReorder-BitdUhxn.js → useListReorder-DPynGrCj.js} +3 -3
- package/dist/{useListReorder-BitdUhxn.js.map → useListReorder-DPynGrCj.js.map} +1 -1
- package/dist/{useRackEditor-Q8xO-cCy.js → useRackEditor-B2FrQA0p.js} +7 -7
- package/dist/{useRackEditor-Q8xO-cCy.js.map → useRackEditor-B2FrQA0p.js.map} +1 -1
- package/dist/{useRealtime-C3iy9QkU.js → useRealtime-yWjTTnc9.js} +2 -2
- package/dist/{useRealtime-C3iy9QkU.js.map → useRealtime-yWjTTnc9.js.map} +1 -1
- package/dist/{useRequest-D506eHi8.js → useRequest-Dqw12k2s.js} +3 -3
- package/dist/{useRequest-D506eHi8.js.map → useRequest-Dqw12k2s.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/ExperimentSelectorModal.test.ts +34 -1
- package/src/__tests__/components/SettingsModal.test.ts +9 -2
- package/src/__tests__/stores/settings.test.ts +21 -21
- package/src/__tests__/styles/menusShellRefresh.test.ts +8 -2
- package/src/__tests__/styles/paletteTokens.test.ts +44 -0
- package/src/components/AppTopBar.story.vue +59 -4
- package/src/components/AppTopBar.vue +107 -8
- package/src/components/ExperimentSelectorModal.story.vue +38 -0
- package/src/components/ExperimentSelectorModal.vue +20 -1
- package/src/components/SettingsModal.vue +7 -12
- package/src/components/internal/AppTopBarPageSelectorInternal.vue +140 -72
- package/src/stores/index.ts +0 -2
- package/src/stores/settings.ts +20 -50
- package/src/styles/components/app-page-selector.css +120 -45
- package/src/styles/components/app-top-bar.css +155 -0
- package/src/styles/components/experiment-selector-modal.css +13 -0
- package/src/styles/variables.css +121 -15
- package/src/types/index.ts +1 -0
- package/src/types/jobs.ts +10 -0
- package/src/types/platform.ts +1 -1
- package/dist/ExperimentSelectorModal-65peG4Up.js.map +0 -1
- package/dist/SettingsModal-DO3IZ7qz.js.map +0 -1
- package/dist/experiment-43OJtH7R.js.map +0 -1
|
@@ -4,14 +4,26 @@ import Avatar from './Avatar.vue'
|
|
|
4
4
|
import PluginIcon from './PluginIcon.vue'
|
|
5
5
|
import { ExperimentProvider } from '../__stories__/experiment-helpers'
|
|
6
6
|
import { providePluginIdentity } from '../composables/pluginIdentity'
|
|
7
|
-
import type
|
|
7
|
+
import { ref, type App } from 'vue'
|
|
8
8
|
import type {
|
|
9
9
|
TopBarVariant,
|
|
10
10
|
PillNavItem,
|
|
11
11
|
PageSelectorItem,
|
|
12
12
|
PluginInfo,
|
|
13
|
+
TopBarJob,
|
|
13
14
|
} from '../types'
|
|
14
15
|
|
|
16
|
+
const runSlotJobs: { label: string; job: TopBarJob | null }[] = [
|
|
17
|
+
{ label: 'Idle', job: null },
|
|
18
|
+
{ label: 'Running', job: { status: 'running', label: 'Extracting', percent: 40 } },
|
|
19
|
+
{ label: 'Needs input', job: { status: 'awaiting_input', label: 'Decoding', percent: 62 } },
|
|
20
|
+
{ label: 'Saved', job: { status: 'completed', label: 'Saved to EXP-0142' } },
|
|
21
|
+
{ label: 'Failed', job: { status: 'failed', label: '1 failed' } },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
// An index, not the job object: Histoire merges object state across updates.
|
|
25
|
+
const liveRow = ref(0)
|
|
26
|
+
|
|
15
27
|
const variants: TopBarVariant[] = ['card', 'default']
|
|
16
28
|
|
|
17
29
|
const samplePillNav: PillNavItem[] = [
|
|
@@ -43,10 +55,12 @@ const samplePageSelector: PageSelectorItem[] = [
|
|
|
43
55
|
{ id: 'admin', label: 'Admin' },
|
|
44
56
|
]
|
|
45
57
|
|
|
58
|
+
// The menu shows each hint as a second line; a page without one stays a single line.
|
|
46
59
|
const samplePluginPages: PageSelectorItem[] = [
|
|
47
|
-
{ id: 'peaks', label: 'Peak integration' },
|
|
48
|
-
{ id: 'calibration', label: 'Calibration' },
|
|
49
|
-
{ id: 'report', label: 'Report' },
|
|
60
|
+
{ id: 'peaks', label: 'Peak integration', hint: 'Integrate peaks and review the fits per sample' },
|
|
61
|
+
{ id: 'calibration', label: 'Calibration', hint: 'Fit standard curves and check their residuals' },
|
|
62
|
+
{ id: 'report', label: 'Report', hint: 'Export concentrations for the experiment' },
|
|
63
|
+
{ id: 'settings', label: 'Settings' },
|
|
50
64
|
]
|
|
51
65
|
|
|
52
66
|
const samplePlugin: PluginInfo = {
|
|
@@ -147,6 +161,47 @@ function seedIdentity({ app }: { app: App }) {
|
|
|
147
161
|
</div>
|
|
148
162
|
</Variant>
|
|
149
163
|
|
|
164
|
+
<Variant title="Run slot · Experiment and job">
|
|
165
|
+
<!-- The plugin picks the viewer's own job; the bar joins it to the experiment chip. Like LEAF Extract: no Save. -->
|
|
166
|
+
<div style="padding: 2rem; background: var(--bg-primary); display: grid; gap: 0.75rem;">
|
|
167
|
+
<ExperimentProvider :plugin="samplePlugin" :save="false">
|
|
168
|
+
<AppTopBar
|
|
169
|
+
v-for="row in runSlotJobs"
|
|
170
|
+
:key="row.label"
|
|
171
|
+
:spec="{ themeToggle: true, homePath: '' }"
|
|
172
|
+
:page-selector="samplePluginPages"
|
|
173
|
+
current-page-selector-id="peaks"
|
|
174
|
+
:job="row.job"
|
|
175
|
+
/>
|
|
176
|
+
</ExperimentProvider>
|
|
177
|
+
</div>
|
|
178
|
+
</Variant>
|
|
179
|
+
|
|
180
|
+
<Variant title="Run slot · Animation">
|
|
181
|
+
<div style="padding: 2rem; background: var(--bg-primary); display: grid; gap: 0.75rem;">
|
|
182
|
+
<div style="display: flex; gap: 0.5rem;">
|
|
183
|
+
<button
|
|
184
|
+
v-for="(row, index) in runSlotJobs"
|
|
185
|
+
:key="row.label"
|
|
186
|
+
type="button"
|
|
187
|
+
data-run-slot-state
|
|
188
|
+
style="padding: 0.25rem 0.75rem; border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-card); color: var(--text-primary); cursor: pointer;"
|
|
189
|
+
@click="liveRow = index"
|
|
190
|
+
>
|
|
191
|
+
{{ row.label }}
|
|
192
|
+
</button>
|
|
193
|
+
</div>
|
|
194
|
+
<ExperimentProvider :plugin="samplePlugin" :save="false">
|
|
195
|
+
<AppTopBar
|
|
196
|
+
:spec="{ themeToggle: true, homePath: '' }"
|
|
197
|
+
:page-selector="samplePluginPages"
|
|
198
|
+
current-page-selector-id="peaks"
|
|
199
|
+
:job="runSlotJobs[liveRow].job"
|
|
200
|
+
/>
|
|
201
|
+
</ExperimentProvider>
|
|
202
|
+
</div>
|
|
203
|
+
</Variant>
|
|
204
|
+
|
|
150
205
|
<Variant title="Refresh · Admin">
|
|
151
206
|
<div style="padding: 2rem; background: var(--bg-primary); min-height: 12rem;">
|
|
152
207
|
<AppTopBar
|
|
@@ -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
|
|
|
@@ -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"
|
|
@@ -358,13 +358,8 @@ const themeOptions: { value: ThemeMode; label: string; halves: ('light' | 'dark'
|
|
|
358
358
|
{ value: 'system', label: 'System', halves: ['light', 'dark'] },
|
|
359
359
|
]
|
|
360
360
|
|
|
361
|
-
/**
|
|
362
|
-
|
|
363
|
-
const [from, to] = colorPalettes[key].hues
|
|
364
|
-
const span = ((to - from + 540) % 360) - 180
|
|
365
|
-
const tone = key === 'pastel' ? '60% 72%' : '65% 48%'
|
|
366
|
-
return Array.from({ length: 5 }, (_, i) => `hsl(${Math.round(from + (span * i) / 4)} ${tone})`)
|
|
367
|
-
}
|
|
361
|
+
/** Accent tokens a palette swatch shows; `data-palette` on the strip resolves them per palette. */
|
|
362
|
+
const paletteSwatchTokens = ['--color-primary', '--color-primary-hover', '--color-primary-soft', '--color-primary-border']
|
|
368
363
|
|
|
369
364
|
const densityPreviewRows = [
|
|
370
365
|
['S-0012', '2.41', '12.5'],
|
|
@@ -562,7 +557,7 @@ function canShowForCurrentUser(item: AccessControlled): boolean {
|
|
|
562
557
|
<div class="mint-settings-modal__setting">
|
|
563
558
|
<div class="mint-settings-modal__setting-text">
|
|
564
559
|
<span class="mint-settings-modal__setting-label">Color palette</span>
|
|
565
|
-
<span class="mint-settings-modal__setting-hint">
|
|
560
|
+
<span class="mint-settings-modal__setting-hint">Accent colour across the interface</span>
|
|
566
561
|
</div>
|
|
567
562
|
<div class="mint-settings-modal__palettes">
|
|
568
563
|
<button
|
|
@@ -573,12 +568,12 @@ function canShowForCurrentUser(item: AccessControlled): boolean {
|
|
|
573
568
|
:class="['mint-settings-modal__palette', { 'mint-settings-modal__palette--active': settings.colorPalette === key }]"
|
|
574
569
|
@click="settings.colorPalette = key as ColorPalette"
|
|
575
570
|
>
|
|
576
|
-
<span class="mint-settings-modal__swatches" aria-hidden="true">
|
|
571
|
+
<span class="mint-settings-modal__swatches" :data-palette="key" aria-hidden="true">
|
|
577
572
|
<span
|
|
578
|
-
v-for="
|
|
579
|
-
:key="
|
|
573
|
+
v-for="token in paletteSwatchTokens"
|
|
574
|
+
:key="token"
|
|
580
575
|
class="mint-settings-modal__swatch"
|
|
581
|
-
:style="{ backgroundColor:
|
|
576
|
+
:style="{ backgroundColor: `var(${token})` }"
|
|
582
577
|
/>
|
|
583
578
|
</span>
|
|
584
579
|
<span class="mint-settings-modal__palette-name">{{ palette.name }}</span>
|
|
@@ -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>
|
package/src/stores/index.ts
CHANGED