@morscherlab/mint-sdk 1.0.14 → 1.0.16

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 (58) hide show
  1. package/dist/{BaseSelect-DksaKYq_.js → BaseSelect-ekgr9fDo.js} +4 -1
  2. package/dist/BaseSelect-ekgr9fDo.js.map +1 -0
  3. package/dist/{ExperimentSelectorModal-DIFyL5ta.js → ExperimentSelectorModal-BOzDs8TU.js} +2 -2
  4. package/dist/{ExperimentSelectorModal-CHsU-LIh.js → ExperimentSelectorModal-CX0oBzpV.js} +2 -2
  5. package/dist/{ExperimentSelectorModal-CHsU-LIh.js.map → ExperimentSelectorModal-CX0oBzpV.js.map} +1 -1
  6. package/dist/{SettingsModal-LEKI6Ebl.js → SettingsModal-BTyXD0uP.js} +3 -3
  7. package/dist/{SettingsModal-LEKI6Ebl.js.map → SettingsModal-BTyXD0uP.js.map} +1 -1
  8. package/dist/SettingsModal-DXcSKk9D.js +5 -0
  9. package/dist/__tests__/components/MobileSupportGate.test.d.ts +1 -0
  10. package/dist/__tests__/composables/useMobileSupportGate.test.d.ts +1 -0
  11. package/dist/components/AutoGroupModal.vue.d.ts +6 -0
  12. package/dist/components/BaseInput.vue.d.ts +1 -0
  13. package/dist/components/MobileSupportGate.vue.d.ts +40 -0
  14. package/dist/components/SampleSelector.colors.d.ts +2 -1
  15. package/dist/components/index.d.ts +1 -0
  16. package/dist/components/index.js +6 -6
  17. package/dist/{components-Dq02EVZH.js → components-D0CzE0XK.js} +1094 -500
  18. package/dist/components-D0CzE0XK.js.map +1 -0
  19. package/dist/composables/index.d.ts +1 -0
  20. package/dist/composables/index.js +7 -7
  21. package/dist/composables/useMobileSupportGate.d.ts +14 -0
  22. package/dist/{composables-D9mexHSW.js → composables-Da-4XOe2.js} +3 -3
  23. package/dist/{composables-D9mexHSW.js.map → composables-Da-4XOe2.js.map} +1 -1
  24. package/dist/index.js +10 -10
  25. package/dist/install.js +5 -5
  26. package/dist/styles.css +3705 -2286
  27. package/dist/templates/index.js +3 -3
  28. package/dist/{templates-Do43ZIMb.js → templates-Dnf8UNxg.js} +2 -2
  29. package/dist/{templates-Do43ZIMb.js.map → templates-Dnf8UNxg.js.map} +1 -1
  30. package/dist/{useControlSchema-0n8Bcftq.js → useControlSchema-Dkm-W_lg.js} +2 -2
  31. package/dist/{useControlSchema-0n8Bcftq.js.map → useControlSchema-Dkm-W_lg.js.map} +1 -1
  32. package/dist/{useFormBuilder-COfYWDuC.js → useFormBuilder-BOJ52N4M.js} +2 -2
  33. package/dist/{useFormBuilder-COfYWDuC.js.map → useFormBuilder-BOJ52N4M.js.map} +1 -1
  34. package/dist/{useProtocolTemplates-DODHlhxr.js → useProtocolTemplates-r2GOnnH1.js} +55 -5
  35. package/dist/useProtocolTemplates-r2GOnnH1.js.map +1 -0
  36. package/package.json +1 -1
  37. package/src/__tests__/components/MobileSupportGate.test.ts +120 -0
  38. package/src/__tests__/components/SampleSelector.test.ts +125 -3
  39. package/src/__tests__/components/SampleSelectorSampleRow.test.ts +5 -2
  40. package/src/__tests__/composables/useMobileSupportGate.test.ts +74 -0
  41. package/src/components/AutoGroupModal.story.vue +46 -0
  42. package/src/components/AutoGroupModal.vue +578 -2
  43. package/src/components/BaseInput.vue +2 -0
  44. package/src/components/MobileSupportGate.story.vue +52 -0
  45. package/src/components/MobileSupportGate.vue +115 -0
  46. package/src/components/SampleSelector.colors.ts +7 -2
  47. package/src/components/SampleSelector.story.vue +34 -0
  48. package/src/components/SampleSelector.vue +24 -1
  49. package/src/components/SampleSelectorSampleRow.vue +2 -0
  50. package/src/components/index.ts +1 -0
  51. package/src/composables/index.ts +8 -0
  52. package/src/composables/useMobileSupportGate.ts +80 -0
  53. package/src/styles/components/auto-group-modal.css +744 -0
  54. package/src/styles/components/mobile-support-gate.css +119 -0
  55. package/dist/BaseSelect-DksaKYq_.js.map +0 -1
  56. package/dist/SettingsModal-L7Ejny45.js +0 -5
  57. package/dist/components-Dq02EVZH.js.map +0 -1
  58. package/dist/useProtocolTemplates-DODHlhxr.js.map +0 -1
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ import MobileSupportGate from './MobileSupportGate.vue'
3
+ </script>
4
+
5
+ <template>
6
+ <Story title="Layout/MobileSupportGate">
7
+ <Variant title="Unsupported State">
8
+ <div style="min-height: 560px;">
9
+ <MobileSupportGate
10
+ :supported="false"
11
+ media-query="(min-width: 0px)"
12
+ app-name="MINT platform"
13
+ title="Desktop workspace recommended"
14
+ message="This page is not supported on mobile. Experiments, Projects, and Admin remain available on smaller screens."
15
+ desktop-label="Switch to a desktop browser for the full platform workspace."
16
+ >
17
+ <div>Workspace content</div>
18
+ </MobileSupportGate>
19
+ </div>
20
+ </Variant>
21
+
22
+ <Variant title="Supported Content">
23
+ <div style="padding: 2rem; min-height: 360px;">
24
+ <MobileSupportGate :supported="true" media-query="(min-width: 0px)">
25
+ <div style="max-width: 640px; border: 1px solid var(--border-color, #e2e8f0); border-radius: 0.5rem; padding: 1.5rem; background: var(--bg-secondary, #fff);">
26
+ <h3 style="margin: 0 0 0.5rem; font-size: 1rem; line-height: 1.2;">Route content</h3>
27
+ <p style="margin: 0; color: var(--text-secondary, #475569);">
28
+ The gate renders its default slot whenever the route is supported or the viewport is wider than the mobile query.
29
+ </p>
30
+ </div>
31
+ </MobileSupportGate>
32
+ </div>
33
+ </Variant>
34
+
35
+ <Variant title="Custom Unsupported Slot">
36
+ <div style="min-height: 420px;">
37
+ <MobileSupportGate :supported="false" media-query="(min-width: 0px)">
38
+ <template #unsupported>
39
+ <div style="min-height: 420px; display: grid; place-items: center; padding: 2rem; background: var(--bg-primary, #f8fafc);">
40
+ <div style="max-width: 420px; border: 1px solid var(--border-color, #e2e8f0); border-radius: 0.5rem; padding: 1.5rem; background: var(--bg-secondary, #fff);">
41
+ <h3 style="margin: 0 0 0.5rem; font-size: 1.125rem;">Custom fallback</h3>
42
+ <p style="margin: 0; color: var(--text-secondary, #475569); line-height: 1.5;">
43
+ Consumers can replace the default message while keeping the SDK viewport detection behavior.
44
+ </p>
45
+ </div>
46
+ </div>
47
+ </template>
48
+ </MobileSupportGate>
49
+ </div>
50
+ </Variant>
51
+ </Story>
52
+ </template>
@@ -0,0 +1,115 @@
1
+ <script lang="ts">
2
+ let nextMobileSupportGateTitleId = 0
3
+ </script>
4
+
5
+ <script setup lang="ts">
6
+ import { computed } from 'vue'
7
+ import {
8
+ DEFAULT_MOBILE_VIEWPORT_QUERY,
9
+ useMobileSupportGate,
10
+ } from '../composables/useMobileSupportGate'
11
+
12
+ export interface MobileSupportGateProps {
13
+ supported?: boolean
14
+ mediaQuery?: string
15
+ appName?: string
16
+ title?: string
17
+ message?: string
18
+ desktopLabel?: string
19
+ }
20
+
21
+ const props = withDefaults(defineProps<MobileSupportGateProps>(), {
22
+ supported: true,
23
+ mediaQuery: DEFAULT_MOBILE_VIEWPORT_QUERY,
24
+ appName: 'MINT',
25
+ title: 'Desktop workspace recommended',
26
+ message: 'This workspace is optimized for a wider desktop screen and is not supported on mobile.',
27
+ desktopLabel: 'Open this page from a desktop browser to use the full workflow.',
28
+ })
29
+
30
+ defineSlots<{
31
+ default(props: { isMobileViewport: boolean; isSupported: boolean }): unknown
32
+ unsupported?(props: { isMobileViewport: boolean; isSupported: boolean }): unknown
33
+ }>()
34
+
35
+ const titleId = `mint-mobile-support-title-${++nextMobileSupportGateTitleId}`
36
+
37
+ const supported = computed(() => props.supported)
38
+ const mediaQuery = computed(() => props.mediaQuery)
39
+
40
+ const {
41
+ isMobileViewport,
42
+ isSupported,
43
+ shouldShowUnsupported,
44
+ } = useMobileSupportGate({
45
+ supported,
46
+ mediaQuery,
47
+ })
48
+ </script>
49
+
50
+ <template>
51
+ <div
52
+ v-if="shouldShowUnsupported"
53
+ class="mint-mobile-support-gate"
54
+ role="main"
55
+ :aria-labelledby="titleId"
56
+ data-testid="mobile-unsupported"
57
+ >
58
+ <slot
59
+ name="unsupported"
60
+ :is-mobile-viewport="isMobileViewport"
61
+ :is-supported="isSupported"
62
+ >
63
+ <section class="mint-mobile-support-gate__panel">
64
+ <div class="mint-mobile-support-gate__mark" aria-hidden="true">
65
+ <svg
66
+ class="mint-mobile-support-gate__icon"
67
+ viewBox="0 0 24 24"
68
+ fill="none"
69
+ stroke="currentColor"
70
+ stroke-width="2"
71
+ stroke-linecap="round"
72
+ stroke-linejoin="round"
73
+ >
74
+ <rect x="3" y="4" width="18" height="12" rx="2" />
75
+ <path d="M8 20h8" />
76
+ <path d="M12 16v4" />
77
+ </svg>
78
+ </div>
79
+
80
+ <div class="mint-mobile-support-gate__copy">
81
+ <p class="mint-mobile-support-gate__eyebrow">{{ appName }}</p>
82
+ <h1 :id="titleId" class="mint-mobile-support-gate__title">{{ title }}</h1>
83
+ <p class="mint-mobile-support-gate__message">{{ message }}</p>
84
+ </div>
85
+
86
+ <div class="mint-mobile-support-gate__hint">
87
+ <svg
88
+ class="mint-mobile-support-gate__hint-icon"
89
+ viewBox="0 0 24 24"
90
+ fill="none"
91
+ stroke="currentColor"
92
+ stroke-width="2"
93
+ stroke-linecap="round"
94
+ stroke-linejoin="round"
95
+ aria-hidden="true"
96
+ >
97
+ <path d="M12 20h9" />
98
+ <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
99
+ </svg>
100
+ <span>{{ desktopLabel }}</span>
101
+ </div>
102
+ </section>
103
+ </slot>
104
+ </div>
105
+
106
+ <slot
107
+ v-else
108
+ :is-mobile-viewport="isMobileViewport"
109
+ :is-supported="isSupported"
110
+ />
111
+ </template>
112
+
113
+ <style>
114
+ @import '../styles/components/mobile-support-gate.css';
115
+ </style>
@@ -6,6 +6,7 @@ export type ColorEdit =
6
6
  | { kind: 'family'; names: string[] }
7
7
 
8
8
  export const DEFAULT_COLOR_PICKER_SEED = '#3B82F6'
9
+ export const SAMPLE_GROUP_COLOR_OPTIONS = DEFAULT_COLORS.slice(0, 5)
9
10
 
10
11
  export function applySampleGroupColorEdit(
11
12
  groups: SampleGroup[],
@@ -31,13 +32,17 @@ export function pickUnusedSampleGroupColor(groups: SampleGroup[]): string {
31
32
  return DEFAULT_COLORS.find(color => !usedColors.has(color)) || DEFAULT_COLORS[0]
32
33
  }
33
34
 
34
- export function createSampleGroup(name: string, existingGroups: SampleGroup[]): SampleGroup | null {
35
+ export function createSampleGroup(
36
+ name: string,
37
+ existingGroups: SampleGroup[],
38
+ color?: string,
39
+ ): SampleGroup | null {
35
40
  const trimmedName = name.trim()
36
41
  if (!trimmedName) return null
37
42
 
38
43
  return {
39
44
  name: trimmedName,
40
- color: pickUnusedSampleGroupColor(existingGroups),
45
+ color: color || pickUnusedSampleGroupColor(existingGroups),
41
46
  samples: [],
42
47
  }
43
48
  }
@@ -17,6 +17,29 @@ const mockGroups: SampleGroup[] = [
17
17
  { name: 'Vehicle', color: '#F59E0B', samples: ['Vehicle_Rep1', 'Vehicle_Rep2', 'Vehicle_Rep3'] },
18
18
  ]
19
19
 
20
+ const manualSamples = [
21
+ 'Pt001_TumorA_d7_rep1',
22
+ 'Pt001_TumorA_d7_rep1_reseq2024',
23
+ 'Pt001_TumorA_d14_rep1',
24
+ 'Pt002_TumorB_d7_rep1',
25
+ 'Pt002_tumorb_d7_rep2',
26
+ 'Pt002_TumorB_d14_rep1',
27
+ 'Pt003_NoID_d7_rep1',
28
+ 'Pt003_TumorC_d14_rep1_redo',
29
+ 'Ctrl_pooled_old_naming',
30
+ 'control_2_(redo)',
31
+ 'Pt004_TumorA_d7_2024batch2',
32
+ 'Pt004_TumorA_d14',
33
+ 'QC_pool_01',
34
+ 'blank_run3',
35
+ ]
36
+
37
+ const manualGroups: SampleGroup[] = [
38
+ { name: 'Responder/Day 7', color: '#0EA5A4', samples: ['Pt001_TumorA_d7_rep1'] },
39
+ { name: 'Responder/Day 14', color: '#0EA5A4', samples: ['Pt001_TumorA_d14_rep1'] },
40
+ { name: 'Non-responder/Day 7', color: '#F43F5E', samples: ['Pt002_TumorB_d7_rep1'] },
41
+ ]
42
+
20
43
  // Demonstrates same-hue gradient for sub-groups inside a major group:
21
44
  // each Treatment_* gets a shade of the major group's color.
22
45
  const hierarchicalSamples = [
@@ -83,6 +106,17 @@ function handleSmartGroup(result: AutoGroupResult, state: { groups: SampleGroup[
83
106
  </div>
84
107
  </Variant>
85
108
 
109
+ <Variant title="With Manual Group Button">
110
+ <div style="padding: 2rem; max-width: 1040px;">
111
+ <SampleSelector
112
+ :model-value="[]"
113
+ :samples="manualSamples"
114
+ :groups="manualGroups"
115
+ :enable-smart-group="false"
116
+ />
117
+ </div>
118
+ </Variant>
119
+
86
120
  <Variant title="Hierarchical Color Family">
87
121
  <div style="padding: 2rem; max-width: 460px;">
88
122
  <SampleSelector
@@ -54,6 +54,7 @@ const emit = defineEmits<{
54
54
 
55
55
  // UI State
56
56
  const showSmartGroupModal = ref(false)
57
+ const groupingModalMode = ref<'auto' | 'manual'>('auto')
57
58
  const newGroupName = ref('')
58
59
  const editingColor = ref<ColorEdit | null>(null)
59
60
  const colorPickerInput = ref<HTMLInputElement | null>(null)
@@ -165,6 +166,11 @@ function handleSmartGroupApply(result: AutoGroupResult) {
165
166
  emit('update:groups', result.groups)
166
167
  }
167
168
 
169
+ function openGroupingModal(mode: 'auto' | 'manual') {
170
+ groupingModalMode.value = mode
171
+ showSmartGroupModal.value = true
172
+ }
173
+
168
174
  // Group management
169
175
  function clearGroups() {
170
176
  internalGroups.value = []
@@ -247,7 +253,7 @@ defineExpose({ handleSmartGroupApply })
247
253
  size="sm"
248
254
  :disabled="resolvedSamples.length === 0"
249
255
  class="mint-sample-selector__action-btn"
250
- @click="showSmartGroupModal = true"
256
+ @click="openGroupingModal('auto')"
251
257
  >
252
258
  <svg class="mint-sample-selector__action-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
253
259
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
@@ -255,6 +261,19 @@ defineExpose({ handleSmartGroupApply })
255
261
  <span>Smart Group</span>
256
262
  </BaseButton>
257
263
 
264
+ <BaseButton
265
+ variant="secondary"
266
+ size="sm"
267
+ :disabled="resolvedSamples.length === 0"
268
+ class="mint-sample-selector__action-btn"
269
+ @click="openGroupingModal('manual')"
270
+ >
271
+ <svg class="mint-sample-selector__action-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
272
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3 3 7.5l9 4.5 9-4.5L12 3ZM3 12l9 4.5 9-4.5M3 16.5l9 4.5 9-4.5" />
273
+ </svg>
274
+ <span>Manual Group</span>
275
+ </BaseButton>
276
+
258
277
  <!-- Reset Button -->
259
278
  <BaseButton
260
279
  variant="ghost"
@@ -673,6 +692,8 @@ defineExpose({ handleSmartGroupApply })
673
692
  v-for="sample in filteredSamples"
674
693
  :key="sample"
675
694
  class="mint-sample-selector__flat-item"
695
+ :title="sample"
696
+ :aria-label="`Sample: ${sample}`"
676
697
  >
677
698
  <input
678
699
  type="checkbox"
@@ -693,6 +714,8 @@ defineExpose({ handleSmartGroupApply })
693
714
  <AutoGroupModal
694
715
  v-model="showSmartGroupModal"
695
716
  :samples="resolvedSamples"
717
+ :groups="internalGroups"
718
+ :initial-mode="groupingModalMode"
696
719
  :experiment-id="resolvedExperimentId"
697
720
  :design-data="resolvedDesignData"
698
721
  @apply="handleSmartGroupApply"
@@ -36,6 +36,8 @@ const checkboxClasses = computed(() => [
36
36
  'mint-sample-selector__sample',
37
37
  dragging ? 'mint-sample-selector__sample--dragging' : '',
38
38
  ]"
39
+ :title="sample"
40
+ :aria-label="`Sample: ${sample}`"
39
41
  draggable="true"
40
42
  >
41
43
  <svg class="mint-sample-selector__drag-handle" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -40,6 +40,7 @@ export { default as AppAvatarMenu } from './AppAvatarMenu.vue'
40
40
  export { default as AppPluginSwitcher } from './AppPluginSwitcher.vue'
41
41
  export { default as AppSidebar } from './AppSidebar.vue'
42
42
  export { default as AppLayout } from './AppLayout.vue'
43
+ export { default as MobileSupportGate } from './MobileSupportGate.vue'
43
44
  export { default as PluginWorkspaceView } from './PluginWorkspaceView.vue'
44
45
  export { default as ComponentBindingRenderer } from './ComponentBindingRenderer.vue'
45
46
  export { default as ControlWorkspaceView } from './ControlWorkspaceView.vue'
@@ -102,6 +102,14 @@ export {
102
102
  type UseEventListenerObjectOptions,
103
103
  type UseEventListenerOptions,
104
104
  } from './useEventListener'
105
+ export {
106
+ DEFAULT_MOBILE_VIEWPORT_QUERY,
107
+ useMobileSupportGate,
108
+ type MobileSupportSource,
109
+ type MobileViewportQuerySource,
110
+ type UseMobileSupportGateOptions,
111
+ type UseMobileSupportGateReturn,
112
+ } from './useMobileSupportGate'
105
113
  export {
106
114
  useDebouncedWatch,
107
115
  type DebouncedWatchCallback,
@@ -0,0 +1,80 @@
1
+ import { computed, onMounted, onUnmounted, ref, unref, watch, type ComputedRef, type Ref } from 'vue'
2
+
3
+ export const DEFAULT_MOBILE_VIEWPORT_QUERY = '(max-width: 767px)'
4
+
5
+ export type MobileSupportSource = boolean | Ref<boolean> | ComputedRef<boolean>
6
+ export type MobileViewportQuerySource = string | Ref<string> | ComputedRef<string>
7
+
8
+ export interface UseMobileSupportGateOptions {
9
+ supported?: MobileSupportSource
10
+ mediaQuery?: MobileViewportQuerySource
11
+ }
12
+
13
+ export interface UseMobileSupportGateReturn {
14
+ isMobileViewport: Ref<boolean>
15
+ isSupported: ComputedRef<boolean>
16
+ shouldShowUnsupported: ComputedRef<boolean>
17
+ }
18
+
19
+ export function useMobileSupportGate(options: UseMobileSupportGateOptions = {}): UseMobileSupportGateReturn {
20
+ const isMobileViewport = ref(false)
21
+
22
+ const mediaQuery = computed(() => {
23
+ const value = unref(options.mediaQuery)
24
+ return value?.trim() || DEFAULT_MOBILE_VIEWPORT_QUERY
25
+ })
26
+
27
+ const isSupported = computed(() => {
28
+ if (options.supported === undefined) return true
29
+ return Boolean(unref(options.supported))
30
+ })
31
+
32
+ const shouldShowUnsupported = computed(() => isMobileViewport.value && !isSupported.value)
33
+
34
+ let mediaQueryList: MediaQueryList | null = null
35
+ let removeMediaListener: (() => void) | undefined
36
+
37
+ const syncMobileState = (event?: MediaQueryListEvent | MediaQueryList) => {
38
+ isMobileViewport.value = Boolean(event?.matches ?? mediaQueryList?.matches)
39
+ }
40
+
41
+ const unbindMediaQuery = () => {
42
+ removeMediaListener?.()
43
+ removeMediaListener = undefined
44
+ mediaQueryList = null
45
+ }
46
+
47
+ const bindMediaQuery = () => {
48
+ unbindMediaQuery()
49
+
50
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
51
+ isMobileViewport.value = false
52
+ return
53
+ }
54
+
55
+ const queryList = window.matchMedia(mediaQuery.value)
56
+ mediaQueryList = queryList
57
+ syncMobileState(queryList)
58
+
59
+ const handleChange = (event: MediaQueryListEvent) => syncMobileState(event)
60
+
61
+ if (typeof queryList.addEventListener === 'function') {
62
+ queryList.addEventListener('change', handleChange)
63
+ removeMediaListener = () => queryList.removeEventListener('change', handleChange)
64
+ return
65
+ }
66
+
67
+ queryList.addListener(handleChange)
68
+ removeMediaListener = () => queryList.removeListener(handleChange)
69
+ }
70
+
71
+ onMounted(bindMediaQuery)
72
+ watch(mediaQuery, bindMediaQuery, { flush: 'post' })
73
+ onUnmounted(unbindMediaQuery)
74
+
75
+ return {
76
+ isMobileViewport,
77
+ isSupported,
78
+ shouldShowUnsupported,
79
+ }
80
+ }