@morscherlab/mint-sdk 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/{ExperimentSelectorModal-DCXmwKNS.js → ExperimentSelectorModal-DHE9k8TP.js} +3 -3
  2. package/dist/{ExperimentSelectorModal-DCXmwKNS.js.map → ExperimentSelectorModal-DHE9k8TP.js.map} +1 -1
  3. package/dist/{SettingsModal-BYR20I4c.js → SettingsModal-DOcCf0Vo.js} +39 -16
  4. package/dist/SettingsModal-DOcCf0Vo.js.map +1 -0
  5. package/dist/{Skeleton-BAF3CKY7.js → Skeleton-jhF9wUkU.js} +17 -7
  6. package/dist/Skeleton-jhF9wUkU.js.map +1 -0
  7. package/dist/__tests__/components/FormField.test.d.ts +1 -0
  8. package/dist/__tests__/components/NumberInput.test.d.ts +1 -0
  9. package/dist/__tests__/composables/localFiles.test.d.ts +1 -0
  10. package/dist/__tests__/composables/usePickerSelection.test.d.ts +1 -0
  11. package/dist/__tests__/styles/tokenOnlyColors.test.d.ts +1 -0
  12. package/dist/components/AppSidebar.vue.d.ts +7 -7
  13. package/dist/components/BasePill.vue.d.ts +3 -0
  14. package/dist/components/BaseTabs.vue.d.ts +1 -4
  15. package/dist/components/FileUploader.vue.d.ts +3 -0
  16. package/dist/components/FormField.vue.d.ts +8 -1
  17. package/dist/components/PluginWorkspaceView.props.d.ts +3 -3
  18. package/dist/components/index.js +5 -5
  19. package/dist/{components-DIum5hkx.js → components-Cp8DcNrU.js} +472 -259
  20. package/dist/components-Cp8DcNrU.js.map +1 -0
  21. package/dist/composables/index.js +7 -7
  22. package/dist/composables/pluginWorkspaceModel.d.ts +0 -3
  23. package/dist/composables/useToast.d.ts +22 -1
  24. package/dist/{composables-mFXsTA4g.js → composables-D_4qqOlp.js} +6 -6
  25. package/dist/{composables-mFXsTA4g.js.map → composables-D_4qqOlp.js.map} +1 -1
  26. package/dist/index.js +10 -10
  27. package/dist/install.js +2 -2
  28. package/dist/styles.css +560 -303
  29. package/dist/templates/index.js +2 -2
  30. package/dist/{templates-CmGfmBRA.js → templates-Dd9bBSs6.js} +2 -2
  31. package/dist/{templates-CmGfmBRA.js.map → templates-Dd9bBSs6.js.map} +1 -1
  32. package/dist/types/components.d.ts +21 -0
  33. package/dist/types/form-builder.d.ts +2 -0
  34. package/dist/types/generated-ui.d.ts +2 -0
  35. package/dist/types/index.d.ts +1 -1
  36. package/dist/{useControlSchema-DldpxNOP.js → useControlSchema-BB2GRPhW.js} +151 -71
  37. package/dist/useControlSchema-BB2GRPhW.js.map +1 -0
  38. package/dist/{useExperimentSelector-CEcIaueJ.js → useExperimentSelector-G_CAOFTX.js} +2 -2
  39. package/dist/{useExperimentSelector-CEcIaueJ.js.map → useExperimentSelector-G_CAOFTX.js.map} +1 -1
  40. package/dist/{useFormBuilder-z5L_dUIy.js → useFormBuilder-BnGSdSRG.js} +2 -2
  41. package/dist/{useFormBuilder-z5L_dUIy.js.map → useFormBuilder-BnGSdSRG.js.map} +1 -1
  42. package/dist/{validation-uwoJGEb6.js → validation-CTxuzLIX.js} +21 -9
  43. package/dist/validation-CTxuzLIX.js.map +1 -0
  44. package/package.json +1 -1
  45. package/src/__tests__/components/AppSidebar.test.ts +84 -94
  46. package/src/__tests__/components/AppToastContainer.test.ts +55 -1
  47. package/src/__tests__/components/BioTemplatePresetWorkspaceView.test.ts +0 -21
  48. package/src/__tests__/components/ControlWorkspaceView.test.ts +0 -2
  49. package/src/__tests__/components/DoseDesignWorkspaceView.test.ts +0 -2
  50. package/src/__tests__/components/FormBuilder.test.ts +26 -0
  51. package/src/__tests__/components/FormField.test.ts +26 -0
  52. package/src/__tests__/components/GeneratedPathInput.test.ts +18 -0
  53. package/src/__tests__/components/NumberInput.test.ts +105 -0
  54. package/src/__tests__/components/PluginWorkspaceView.internal.test.ts +1 -2
  55. package/src/__tests__/components/PluginWorkspaceView.test.ts +1 -2
  56. package/src/__tests__/composables/localFiles.test.ts +57 -0
  57. package/src/__tests__/composables/usePickerSelection.test.ts +98 -0
  58. package/src/__tests__/generatedUi.test.ts +111 -0
  59. package/src/__tests__/styles/tokenOnlyColors.test.ts +16 -0
  60. package/src/components/AppLayout.story.vue +1 -1
  61. package/src/components/AppSidebar.story.vue +7 -10
  62. package/src/components/AppSidebar.vue +178 -58
  63. package/src/components/AppToastContainer.vue +65 -23
  64. package/src/components/BasePill.story.vue +2 -0
  65. package/src/components/BasePill.vue +4 -0
  66. package/src/components/BaseTabs.story.vue +0 -42
  67. package/src/components/BaseTabs.vue +3 -6
  68. package/src/components/FileUploader.vue +10 -1
  69. package/src/components/FormField.vue +10 -3
  70. package/src/components/GeneratedPathInput.vue +11 -7
  71. package/src/components/NumberInput.story.vue +32 -13
  72. package/src/components/NumberInput.vue +116 -55
  73. package/src/components/PluginWorkspaceView.props.ts +3 -3
  74. package/src/components/internal/FormFieldRendererInternal.vue +17 -0
  75. package/src/components/internal/WorkspaceSidebarInternal.vue +1 -2
  76. package/src/composables/filePicker/localFiles.ts +21 -14
  77. package/src/composables/filePicker/usePickerSelection.ts +15 -4
  78. package/src/composables/pluginWorkspaceModel.ts +0 -3
  79. package/src/composables/usePluginWorkspace.ts +1 -2
  80. package/src/composables/useToast.ts +21 -5
  81. package/src/generated/schema.ts +58 -18
  82. package/src/styles/components/app-sidebar.css +282 -121
  83. package/src/styles/components/form-builder.css +1 -1
  84. package/src/styles/components/form-field.css +28 -0
  85. package/src/styles/components/number-input.css +73 -91
  86. package/src/styles/components/pill.css +49 -51
  87. package/src/styles/components/segmented-control.css +16 -11
  88. package/src/styles/components/tabs.css +18 -34
  89. package/src/styles/components/toast.css +136 -27
  90. package/src/styles/variables.css +25 -5
  91. package/src/types/components.ts +23 -0
  92. package/src/types/form-builder.ts +2 -0
  93. package/src/types/generated-ui.ts +2 -0
  94. package/src/types/index.ts +2 -0
  95. package/dist/SettingsModal-BYR20I4c.js.map +0 -1
  96. package/dist/Skeleton-BAF3CKY7.js.map +0 -1
  97. package/dist/components-DIum5hkx.js.map +0 -1
  98. package/dist/useControlSchema-DldpxNOP.js.map +0 -1
  99. package/dist/validation-uwoJGEb6.js.map +0 -1
@@ -10,9 +10,13 @@ interface Props {
10
10
  showOptional?: boolean
11
11
  htmlFor?: string
12
12
  fieldId?: string
13
+ /** `row` puts the label left and the control in a fixed 7.5rem column. Use for numbers, toggles, short enums. */
14
+ layout?: 'stacked' | 'row'
13
15
  }
14
16
 
15
- const props = defineProps<Props>()
17
+ const props = withDefaults(defineProps<Props>(), {
18
+ layout: 'stacked',
19
+ })
16
20
 
17
21
  const errorId = computed(() =>
18
22
  props.error && props.fieldId ? `${props.fieldId}-error` : undefined
@@ -26,7 +30,7 @@ const describedBy = computed(() => errorId.value ?? hintId.value)
26
30
  </script>
27
31
 
28
32
  <template>
29
- <div class="mint-form-field">
33
+ <div :class="['mint-form-field', layout === 'row' ? 'mint-form-field--row' : '']">
30
34
  <div v-if="label" class="mint-form-field__label-row">
31
35
  <label
32
36
  :for="htmlFor"
@@ -43,7 +47,10 @@ const describedBy = computed(() => errorId.value ?? hintId.value)
43
47
  </span>
44
48
  </div>
45
49
 
46
- <slot :described-by="describedBy" />
50
+ <div v-if="layout === 'row'" class="mint-form-field__control">
51
+ <slot :described-by="describedBy" />
52
+ </div>
53
+ <slot v-else :described-by="describedBy" />
47
54
 
48
55
  <p v-if="error" :id="errorId" class="mint-form-field__error" role="alert">
49
56
  {{ error }}
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref } from 'vue'
2
+ import { computed, ref, watch } from 'vue'
3
3
 
4
4
  import { createGeneratedAnalysisTransport } from '../composables/useGeneratedAnalysis'
5
5
  import {
@@ -32,6 +32,13 @@ const emit = defineEmits<{
32
32
  }>()
33
33
 
34
34
  const mode = ref<'file' | 'folder'>('file')
35
+ const selections = computed(() => Array.isArray(props.modelValue)
36
+ ? props.modelValue
37
+ : props.modelValue ? [props.modelValue] : [])
38
+ const selectedFiles = computed(() => selections.value.flatMap(selection => selection.files))
39
+ watch(selections, (current) => {
40
+ if (current.length) mode.value = current.every(selection => selection.rootName) ? 'folder' : 'file'
41
+ }, { immediate: true })
35
42
  const artifactOpen = ref(false)
36
43
  const artifactLoading = ref(false)
37
44
  const artifactError = ref('')
@@ -73,12 +80,7 @@ function selectFiles(
73
80
 
74
81
  function removeFiles(files: File[]): void {
75
82
  const removed = new Set(files)
76
- const current = Array.isArray(props.modelValue)
77
- ? props.modelValue
78
- : props.modelValue
79
- ? [props.modelValue]
80
- : []
81
- const remaining = current.flatMap((selection) => {
83
+ const remaining = selections.value.flatMap((selection) => {
82
84
  const indexes = selection.files
83
85
  .map((file, index) => removed.has(file) ? -1 : index)
84
86
  .filter(index => index >= 0)
@@ -186,6 +188,8 @@ function artifactFileReference(detail: AnalysisArtifactDetail): ArtifactFileRefe
186
188
  <FileUploader
187
189
  :key="mode"
188
190
  :mode="mode"
191
+ :files="selectedFiles"
192
+ :root-name="selections[0]?.rootName"
189
193
  :multiple="multiple && mode === 'file'"
190
194
  :disabled="disabled"
191
195
  @upload="selectFiles"
@@ -1,7 +1,20 @@
1
1
  <script setup lang="ts">
2
+ import { ref } from 'vue'
2
3
  import NumberInput from './NumberInput.vue'
3
4
 
4
5
  const sizes = ['sm', 'md', 'lg'] as const
6
+
7
+ const sizeValues = ref<Record<string, number | undefined>>({ sm: 42, md: 42, lg: 42 })
8
+ const samples = ref<number | undefined>(96)
9
+ const temperature = ref<number | undefined>(37)
10
+ const volume = ref<number | undefined>(100)
11
+ const overMax = ref<number | undefined>(999)
12
+ const empty = ref<number | undefined>(undefined)
13
+ const mass = ref<number | undefined>(12.5)
14
+ const ph = ref<number | undefined>(7.4)
15
+ const wells = ref<number | undefined>(96)
16
+ const sliderPh = ref<number | undefined>(7.4)
17
+ const percent = ref<number | undefined>(45)
5
18
  </script>
6
19
 
7
20
  <template>
@@ -39,6 +52,9 @@ const sizes = ['sm', 'md', 'lg'] as const
39
52
  <HstCheckbox v-model="state.disabled" title="Disabled" />
40
53
  <HstCheckbox v-model="state.error" title="Error" />
41
54
  </template>
55
+ <template #initState>
56
+ {{ { value: 42, min: 0, max: 100, step: 1, size: 'md', placeholder: '', disabled: false, error: false } }}
57
+ </template>
42
58
  </Variant>
43
59
 
44
60
  <Variant title="Sizes">
@@ -47,7 +63,7 @@ const sizes = ['sm', 'md', 'lg'] as const
47
63
  <p style="margin: 0 0 0.5rem; font-size: 0.75rem; color: var(--text-muted, #94a3b8); text-transform: uppercase; letter-spacing: 0.05em;">
48
64
  {{ size }}
49
65
  </p>
50
- <NumberInput :model-value="42" :size="size" />
66
+ <NumberInput v-model="sizeValues[size]" :size="size" />
51
67
  </div>
52
68
  </div>
53
69
  </Variant>
@@ -56,22 +72,22 @@ const sizes = ['sm', 'md', 'lg'] as const
56
72
  <div style="padding: 2rem; max-width: 300px; margin: 0 auto; display: flex; flex-direction: column; gap: 1.5rem;">
57
73
  <div>
58
74
  <p style="margin: 0 0 0.5rem; font-size: 0.875rem; color: var(--text-primary, #e2e8f0);">Sample count (0-384)</p>
59
- <NumberInput :model-value="96" :min="0" :max="384" :step="1" placeholder="Samples" />
75
+ <NumberInput v-model="samples" :min="0" :max="384" :step="1" placeholder="Samples" />
60
76
  </div>
61
77
  <div>
62
78
  <p style="margin: 0 0 0.5rem; font-size: 0.875rem; color: var(--text-primary, #e2e8f0);">Temperature (step 0.5)</p>
63
- <NumberInput :model-value="37" :min="0" :max="100" :step="0.5" />
79
+ <NumberInput v-model="temperature" :min="0" :max="100" :step="0.5" />
64
80
  </div>
65
81
  <div>
66
82
  <p style="margin: 0 0 0.5rem; font-size: 0.875rem; color: var(--text-primary, #e2e8f0);">Volume in uL (step 10)</p>
67
- <NumberInput :model-value="100" :min="0" :max="1000" :step="10" />
83
+ <NumberInput v-model="volume" :min="0" :max="1000" :step="10" />
68
84
  </div>
69
85
  </div>
70
86
  </Variant>
71
87
 
72
88
  <Variant title="Error State">
73
89
  <div style="padding: 2rem; max-width: 300px; margin: 0 auto;">
74
- <NumberInput :model-value="999" :max="100" error />
90
+ <NumberInput v-model="overMax" :max="100" error />
75
91
  </div>
76
92
  </Variant>
77
93
 
@@ -83,16 +99,16 @@ const sizes = ['sm', 'md', 'lg'] as const
83
99
 
84
100
  <Variant title="Empty with Placeholder">
85
101
  <div style="padding: 2rem; max-width: 300px; margin: 0 auto;">
86
- <NumberInput placeholder="Enter value..." />
102
+ <NumberInput v-model="empty" placeholder="Enter value..." />
87
103
  </div>
88
104
  </Variant>
89
105
 
90
- <Variant title="Hover-Reveal Steppers (default)">
106
+ <Variant title="Steppers (always visible)">
91
107
  <template #default="{ state }">
92
108
  <div style="padding: 2rem; max-width: 320px; display: flex; flex-direction: column; gap: 1rem;">
93
109
  <NumberInput v-model="state.value" :step="0.5" />
94
110
  <p style="margin: 0; font-size: 0.8125rem; color: var(--text-muted);">
95
- Hover or focus to reveal the ↑/↓ steppers. Rest = clean.
111
+ Two horizontal steppers, hairline-divided. Arrow keys, PageUp/PageDown also step.
96
112
  </p>
97
113
  </div>
98
114
  </template>
@@ -106,16 +122,19 @@ const sizes = ['sm', 'md', 'lg'] as const
106
122
 
107
123
  <Variant title="With unit suffix">
108
124
  <div style="padding: 2rem; max-width: 320px; display: flex; flex-direction: column; gap: 0.75rem;">
109
- <NumberInput :model-value="12.5" unit="mg" :step="0.1" />
110
- <NumberInput :model-value="7.4" unit="pH" :step="0.1" :min="0" :max="14" />
111
- <NumberInput :model-value="96" unit="wells" :step="1" />
125
+ <NumberInput v-model="mass" unit="mg" :step="0.1" />
126
+ <NumberInput v-model="ph" unit="pH" :step="0.1" :min="0" :max="14" />
127
+ <NumberInput v-model="wells" unit="wells" :step="1" />
112
128
  </div>
113
129
  </Variant>
114
130
 
115
131
  <Variant title="Slider mode (min + max provided)">
116
132
  <div style="padding: 2rem; max-width: 320px; display: flex; flex-direction: column; gap: 1rem;">
117
- <NumberInput :model-value="7.4" :min="0" :max="14" :step="0.1" unit="pH" />
118
- <NumberInput :model-value="45" :min="0" :max="100" :step="1" unit="%" />
133
+ <NumberInput v-model="sliderPh" :min="0" :max="14" :step="0.1" unit="pH" />
134
+ <NumberInput v-model="percent" :min="0" :max="100" :step="1" unit="%" />
135
+ <p style="margin: 0; font-size: 0.8125rem; color: var(--text-muted);">
136
+ Drag anywhere in the field (outside the text) to scrub. Home/End jump to min/max.
137
+ </p>
119
138
  </div>
120
139
  </Variant>
121
140
  </Story>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- /** Numeric input with increment/decrement buttons, optional unit label, and slider progress track. */
2
+ /** Numeric input with always-visible steppers, optional unit label, and (with min+max) an in-field drag-to-scrub range fill. */
3
3
  import { computed } from 'vue'
4
4
 
5
5
  interface Props {
@@ -33,7 +33,7 @@ const sliderPercent = computed(() => {
33
33
  if (min === undefined || max === undefined || props.modelValue === undefined) return 0
34
34
  const range = max - min
35
35
  if (range === 0) return 0
36
- return ((props.modelValue - min) / range) * 100
36
+ return Math.min(100, Math.max(0, ((props.modelValue - min) / range) * 100))
37
37
  })
38
38
 
39
39
  const canDecrement = computed(() => {
@@ -55,6 +55,17 @@ function clamp(value: number): number {
55
55
  return result
56
56
  }
57
57
 
58
+ /** Clamp and trim float noise (0.1 + 0.2 → 0.3) without losing real precision. */
59
+ function settle(value: number): number {
60
+ return Number(clamp(value).toFixed(10))
61
+ }
62
+
63
+ /** Snap to the step grid anchored at `min`, for pointer scrubbing. */
64
+ function snap(value: number): number {
65
+ const origin = props.min ?? 0
66
+ return settle(origin + Math.round((value - origin) / props.step) * props.step)
67
+ }
68
+
58
69
  function handleInput(event: Event) {
59
70
  const target = event.target as HTMLInputElement
60
71
  const value = target.value === '' ? undefined : Number(target.value)
@@ -65,24 +76,70 @@ function handleInput(event: Event) {
65
76
  }
66
77
  }
67
78
 
68
- function handleSliderInput(event: Event) {
69
- const target = event.target as HTMLInputElement
70
- const value = Number(target.value)
71
- if (!isNaN(value)) {
72
- emit('update:modelValue', clamp(value))
73
- }
79
+ function nudge(delta: number) {
80
+ if (props.disabled) return
81
+ if (delta < 0 && !canDecrement.value) return
82
+ if (delta > 0 && !canIncrement.value) return
83
+ const current = props.modelValue ?? (delta < 0 ? props.max ?? 0 : props.min ?? 0)
84
+ emit('update:modelValue', settle(current + delta))
74
85
  }
75
86
 
76
87
  function decrement() {
77
- if (props.disabled || !canDecrement.value) return
78
- const current = props.modelValue ?? (props.max ?? 0)
79
- emit('update:modelValue', clamp(current - props.step))
88
+ nudge(-props.step)
80
89
  }
81
90
 
82
91
  function increment() {
83
- if (props.disabled || !canIncrement.value) return
84
- const current = props.modelValue ?? (props.min ?? 0)
85
- emit('update:modelValue', clamp(current + props.step))
92
+ nudge(props.step)
93
+ }
94
+
95
+ // Home/End only apply on the slider surface: inside the text input they must
96
+ // keep moving the caret.
97
+ function handleKeydown(event: KeyboardEvent, allowJump = false) {
98
+ if (props.disabled) return
99
+ switch (event.key) {
100
+ case 'ArrowUp': increment(); break
101
+ case 'ArrowDown': decrement(); break
102
+ case 'PageUp': nudge(props.step * 10); break
103
+ case 'PageDown': nudge(-props.step * 10); break
104
+ case 'Home':
105
+ if (!allowJump || props.min === undefined) return
106
+ emit('update:modelValue', props.min)
107
+ break
108
+ case 'End':
109
+ if (!allowJump || props.max === undefined) return
110
+ emit('update:modelValue', props.max)
111
+ break
112
+ default:
113
+ return
114
+ }
115
+ event.preventDefault()
116
+ }
117
+
118
+ // Drag-to-scrub across the field. Pointerdown on the <input> itself is left
119
+ // alone so click-to-place-caret and double-click-to-select keep working.
120
+ function scrubTo(clientX: number, area: HTMLElement) {
121
+ const rect = area.getBoundingClientRect()
122
+ if (rect.width === 0 || props.min === undefined || props.max === undefined) return
123
+ const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width))
124
+ emit('update:modelValue', snap(props.min + ratio * (props.max - props.min)))
125
+ }
126
+
127
+ function handleScrubStart(event: PointerEvent) {
128
+ if (props.disabled || !isSliderMode.value) return
129
+ if ((event.target as HTMLElement).tagName === 'INPUT') return
130
+ const area = event.currentTarget as HTMLElement
131
+ event.preventDefault()
132
+ area.setPointerCapture?.(event.pointerId)
133
+ scrubTo(event.clientX, area)
134
+ const move = (e: PointerEvent) => scrubTo(e.clientX, area)
135
+ const stop = () => {
136
+ area.removeEventListener('pointermove', move)
137
+ area.removeEventListener('pointerup', stop)
138
+ area.removeEventListener('pointercancel', stop)
139
+ }
140
+ area.addEventListener('pointermove', move)
141
+ area.addEventListener('pointerup', stop)
142
+ area.addEventListener('pointercancel', stop)
86
143
  }
87
144
  </script>
88
145
 
@@ -97,62 +154,66 @@ function increment() {
97
154
  ]"
98
155
  >
99
156
  <div class="mint-number-input__field">
100
- <input
101
- type="number"
102
- :value="modelValue"
103
- :min="min"
104
- :max="max"
105
- :step="step"
106
- :disabled="disabled"
107
- :placeholder="placeholder"
108
- :class="[
109
- 'mint-number-input__input',
110
- `mint-number-input__input--${size}`,
111
- ]"
112
- @input="handleInput"
113
- />
114
-
115
- <span v-if="unit" class="mint-number-input__unit">{{ unit }}</span>
157
+ <div
158
+ class="mint-number-input__scrub"
159
+ :style="isSliderMode ? { '--mint-number-input-fill': `${sliderPercent}%` } : undefined"
160
+ :role="isSliderMode ? 'slider' : undefined"
161
+ :tabindex="isSliderMode && !disabled ? 0 : undefined"
162
+ :aria-valuenow="isSliderMode ? modelValue : undefined"
163
+ :aria-valuemin="isSliderMode ? min : undefined"
164
+ :aria-valuemax="isSliderMode ? max : undefined"
165
+ :aria-orientation="isSliderMode ? 'horizontal' : undefined"
166
+ @pointerdown="handleScrubStart"
167
+ @keydown.self="isSliderMode && handleKeydown($event, true)"
168
+ >
169
+ <template v-if="isSliderMode">
170
+ <span class="mint-number-input__wash" aria-hidden="true" />
171
+ <span class="mint-number-input__rule" aria-hidden="true" />
172
+ </template>
173
+ <input
174
+ type="number"
175
+ :value="modelValue"
176
+ :min="min"
177
+ :max="max"
178
+ :step="step"
179
+ :disabled="disabled"
180
+ :placeholder="placeholder"
181
+ :class="[
182
+ 'mint-number-input__input',
183
+ `mint-number-input__input--${size}`,
184
+ ]"
185
+ @input="handleInput"
186
+ @keydown="handleKeydown"
187
+ />
188
+ <span v-if="unit" class="mint-number-input__unit">{{ unit }}</span>
189
+ </div>
116
190
 
117
191
  <div class="mint-number-input__steppers" aria-hidden="true">
118
192
  <button
119
193
  type="button"
120
194
  tabindex="-1"
121
- aria-label="Increase value"
122
- :disabled="disabled || !canIncrement"
123
- class="mint-number-input__stepper mint-number-input__stepper--up"
124
- @click="increment"
195
+ aria-label="Decrease value"
196
+ :disabled="disabled || !canDecrement"
197
+ class="mint-number-input__stepper mint-number-input__stepper--down"
198
+ @click="decrement"
125
199
  >
126
- <svg viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
127
- <path d="M1 5l4-4 4 4" />
200
+ <svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
201
+ <path d="M2 5h6" />
128
202
  </svg>
129
203
  </button>
130
204
  <button
131
205
  type="button"
132
206
  tabindex="-1"
133
- aria-label="Decrease value"
134
- :disabled="disabled || !canDecrement"
135
- class="mint-number-input__stepper mint-number-input__stepper--down"
136
- @click="decrement"
207
+ aria-label="Increase value"
208
+ :disabled="disabled || !canIncrement"
209
+ class="mint-number-input__stepper mint-number-input__stepper--up"
210
+ @click="increment"
137
211
  >
138
- <svg viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
139
- <path d="M1 1l4 4 4-4" />
212
+ <svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
213
+ <path d="M5 2v6" /><path d="M2 5h6" />
140
214
  </svg>
141
215
  </button>
142
216
  </div>
143
217
  </div>
144
-
145
- <input
146
- v-if="isSliderMode"
147
- type="range"
148
- :value="modelValue ?? min"
149
- :min="min"
150
- :max="max"
151
- :step="step"
152
- :disabled="disabled"
153
- :style="{ '--mint-slider-fill': `${sliderPercent}%` }"
154
- class="mint-number-input__slider"
155
- @input="handleSliderInput"
156
- />
157
218
  </div>
158
219
  </template>
@@ -97,9 +97,9 @@ export interface PluginWorkspaceViewProps {
97
97
  sidebarSubtitle?: string
98
98
  /** Optional compact badge/count rendered in the sidebar header. */
99
99
  sidebarBadge?: string | number
100
- /** AppSidebar visual preset. `analysis` matches the LEAF-style MINT analysis sidebar language. */
100
+ /** @deprecated No-op. AppSidebar has one look now; the former `analysis` preset is the default. */
101
101
  sidebarVariant?: PluginWorkspaceSidebarVariant
102
- /** Sidebar CSS width. Defaults to AppSidebar's variant width. */
102
+ /** Sidebar CSS width. Defaults to AppSidebar's width. */
103
103
  sidebarWidth?: string
104
104
  /** Sidebar position in AppLayout. */
105
105
  sidebarPosition?: 'left' | 'right'
@@ -113,7 +113,7 @@ export interface PluginWorkspaceViewProps {
113
113
  sidebarContentId?: string
114
114
  /** Compact AppSidebar density. */
115
115
  dense?: boolean
116
- /** Show a built-in collapse/expand button in the sidebar chrome. Defaults to AppSidebar's variant behavior. */
116
+ /** Show a built-in collapse/expand button in the sidebar chrome. Defaults to true. */
117
117
  sidebarCollapsible?: boolean
118
118
  /** Sidebar width when collapsed. */
119
119
  sidebarCollapsedWidth?: string
@@ -20,6 +20,23 @@ const isChoiceField = computed(() =>
20
20
  )
21
21
 
22
22
  const componentProps = computed<Record<string, unknown>>(() => {
23
+ if (props.field.type === 'multiselect') {
24
+ const values = Array.isArray(props.resolvedProps.modelValue) ? props.resolvedProps.modelValue : []
25
+ return { ...props.resolvedProps, modelValue: values }
26
+ }
27
+ if (props.field.type === 'tags') {
28
+ const values = Array.isArray(props.resolvedProps.modelValue) ? props.resolvedProps.modelValue : []
29
+ const onUpdate = props.resolvedProps['onUpdate:modelValue'] as ((value: unknown) => void) | undefined
30
+ // TagsInput edits strings; numeric fields are parsed back on the way out so
31
+ // nullable or numeric form data is displayed without being replaced.
32
+ return {
33
+ ...props.resolvedProps,
34
+ modelValue: values.map(String),
35
+ 'onUpdate:modelValue': (next: unknown) => onUpdate?.(
36
+ props.field.itemType === 'number' && Array.isArray(next) ? next.map(Number) : next,
37
+ ),
38
+ }
39
+ }
23
40
  if (!isChoiceField.value) return props.resolvedProps
24
41
 
25
42
  return {
@@ -34,11 +34,10 @@ defineSlots<{
34
34
  :title="sidebar.title"
35
35
  :subtitle="sidebar.subtitle"
36
36
  :badge="sidebar.badge"
37
- :variant="sidebar.variant"
38
37
  :panels="sidebar.panels"
39
38
  :active-view="sidebar.activeView"
40
39
  :floating="sidebar.floating"
41
- :dense="sidebar.dense"
40
+ :density="sidebar.dense ? 'compact' : 'comfortable'"
42
41
  :width="sidebar.width"
43
42
  :side="sidebar.side"
44
43
  :forms="sidebar.forms"
@@ -1,4 +1,4 @@
1
- import type { PickerAdapter, PickerFile, PickerNode } from '../../types/filePicker'
1
+ import type { PickerAdapter, PickerNode } from '../../types/filePicker'
2
2
  import { pickerFormat } from './validation'
3
3
 
4
4
  /** Construct metadata only: no FileReader, arrayBuffer or upload. */
@@ -14,7 +14,10 @@ export function localFilesAdapter(
14
14
  for (let i = 0; i < parts.length - 1; i++) {
15
15
  const path = parts.slice(0, i + 1).join('/')
16
16
  if (!nodes.has(path))
17
- nodes.set(path, { kind: 'folder', name: parts[i]!, path, parentPath: parent })
17
+ nodes.set(path, {
18
+ kind: 'folder', name: parts[i]!, path, parentPath: parent,
19
+ fileCount: 0, bytes: 0, formats: {},
20
+ })
18
21
  parent = path
19
22
  }
20
23
  // Plain file picks can have equal names; preserve both File objects.
@@ -30,20 +33,24 @@ export function localFilesAdapter(
30
33
  file,
31
34
  })
32
35
  }
36
+ const children = new Map<string, PickerNode[]>()
33
37
  for (const node of nodes.values()) {
34
- if (node.kind !== 'folder') continue
35
- const descendants = [...nodes.values()].filter(
36
- (item): item is PickerFile => item.kind === 'file' && item.path.startsWith(`${node.path}/`),
37
- )
38
- node.fileCount = descendants.length
39
- node.bytes = descendants.reduce((sum, file) => sum + (file.bytes ?? 0), 0)
40
- node.formats = {}
41
- descendants.forEach((file) => {
42
- node.formats![file.format] = (node.formats![file.format] ?? 0) + 1
43
- })
38
+ const parent = node.parentPath ?? ''
39
+ const siblings = children.get(parent)
40
+ if (siblings) siblings.push(node)
41
+ else children.set(parent, [node])
42
+ if (node.kind !== 'file') continue
43
+ // Each file contributes once to its ancestors, rather than scanning all
44
+ // files again for every folder in the selection.
45
+ for (let slash = node.path.indexOf('/'); slash >= 0; slash = node.path.indexOf('/', slash + 1)) {
46
+ const folder = nodes.get(node.path.slice(0, slash))
47
+ if (folder?.kind !== 'folder') continue
48
+ folder.fileCount = (folder.fileCount ?? 0) + 1
49
+ folder.bytes = (folder.bytes ?? 0) + (node.bytes ?? 0)
50
+ folder.formats![node.format] = (folder.formats![node.format] ?? 0) + 1
51
+ }
44
52
  }
45
- const list = (path: string) =>
46
- [...nodes.values()].filter((node) => (node.parentPath ?? '') === path)
53
+ const list = (path: string) => [...(children.get(path) ?? [])]
47
54
  return {
48
55
  listRoot: async () => list(''),
49
56
  listChildren: async (path) => list(path),
@@ -231,12 +231,23 @@ export function usePickerSelection(
231
231
  )) void resolve()
232
232
  },
233
233
  )
234
+ const selectedFileCounts = computed(() => {
235
+ const counts = new Map<string, number>()
236
+ for (const file of chosen.value.files) {
237
+ const seen = new Set<string>()
238
+ let parent = file.parentPath
239
+ while (parent && !seen.has(parent)) {
240
+ seen.add(parent)
241
+ counts.set(parent, (counts.get(parent) ?? 0) + 1)
242
+ parent = nav.nodes.value.get(parent)?.parentPath
243
+ }
244
+ }
245
+ return counts
246
+ })
234
247
  function folderProgress(node: PickerNode): string {
235
248
  if (node.kind !== 'folder' || node.fileCount == null) return ''
236
- const allowed = chosen.value.files.filter((file) =>
237
- ancestorSelected(file, new Set([node.path])),
238
- ).length
239
- return `${selected(node) ? allowed : 0} / ${node.fileCount}`
249
+ const allowed = selected(node) ? (selectedFileCounts.value.get(node.path) ?? 0) : 0
250
+ return `${allowed} / ${node.fileCount}`
240
251
  }
241
252
  return {
242
253
  keys,
@@ -16,7 +16,6 @@ import type {
16
16
  ControlSchema,
17
17
  ControlWorkspaceOptions,
18
18
  } from './useControlSchema'
19
- import type { PluginWorkspaceSidebarVariant } from '../components/PluginWorkspaceView.props'
20
19
 
21
20
  /**
22
21
  * Scoped-slot payload shared by PluginWorkspaceView's `default`, `topbar`, and `sidebar`
@@ -125,8 +124,6 @@ export interface PluginWorkspaceSidebarModel {
125
124
  subtitle?: string
126
125
  /** Optional compact badge/count rendered in the sidebar header. */
127
126
  badge?: string | number
128
- /** AppSidebar visual preset. */
129
- variant: PluginWorkspaceSidebarVariant
130
127
  /** Map of view IDs to sidebar tool sections. */
131
128
  panels: Record<string, SidebarToolSection[]>
132
129
  /** Resolved active view, used to pick which panels/forms render. */
@@ -167,7 +167,6 @@ export function usePluginWorkspace(options: UsePluginWorkspaceOptions): UsePlugi
167
167
  title: props.sidebarTitle,
168
168
  subtitle: props.sidebarSubtitle,
169
169
  badge: props.sidebarBadge,
170
- variant: props.sidebarVariant ?? 'analysis',
171
170
  panels: props.panels ?? {},
172
171
  activeView: navigationState.resolvedActiveView.value,
173
172
  floating: false,
@@ -187,7 +186,7 @@ export function usePluginWorkspace(options: UsePluginWorkspaceOptions): UsePlugi
187
186
  formDisabled: props.formDisabled ?? false,
188
187
  formReadonly: props.formReadonly ?? false,
189
188
  formSize: props.formSize ?? 'sm',
190
- collapsible: props.sidebarCollapsible,
189
+ collapsible: props.sidebarCollapsible ?? true,
191
190
  collapsed: props.sidebarCollapsed,
192
191
  defaultCollapsed: props.defaultSidebarCollapsed ?? false,
193
192
  collapsedWidth: props.sidebarCollapsedWidth ?? '3rem',
@@ -1,15 +1,31 @@
1
1
  import { ref } from 'vue'
2
- import type { Toast } from '../types'
2
+ import type { Toast, ToastOptions } from '../types'
3
3
 
4
4
  const toasts = ref<Toast[]>([])
5
5
  let nextId = 0
6
6
 
7
7
  /** Reactive global toast queue with success/error/warning/info variants and auto-dismiss timers. */
8
8
  export function useToast() {
9
- function show(message: string, type: Toast['type'] = 'success', duration = 3500) {
9
+ /** Push a structured toast. `progress: 'indeterminate'` is the in-progress form and never auto-dismisses. */
10
+ function push(options: ToastOptions): number {
10
11
  const id = nextId++
11
- toasts.value.push({ id, message, type, duration })
12
- setTimeout(() => dismiss(id), duration)
12
+ const duration = options.duration ?? 3500
13
+ toasts.value.push({
14
+ id,
15
+ type: 'success',
16
+ ...options,
17
+ message: options.message ?? options.title,
18
+ duration,
19
+ actions: options.actions?.slice(0, 2),
20
+ })
21
+ if (options.progress !== 'indeterminate') {
22
+ setTimeout(() => dismiss(id), duration)
23
+ }
24
+ return id
25
+ }
26
+
27
+ function show(message: string, type: Toast['type'] = 'success', duration = 3500) {
28
+ push({ title: message, type, duration })
13
29
  }
14
30
 
15
31
  function success(message: string, duration = 3500) {
@@ -36,5 +52,5 @@ export function useToast() {
36
52
  toasts.value = []
37
53
  }
38
54
 
39
- return { toasts, show, success, error, warning, info, dismiss, clear }
55
+ return { toasts, push, show, success, error, warning, info, dismiss, clear }
40
56
  }