@morscherlab/mint-sdk 1.2.0-beta.7 → 1.2.0-beta.9
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/README.md +30 -0
- package/dist/{ExperimentPopover-CfOdh5Rm.js → ExperimentPopover-oL0fg_A0.js} +5 -3
- package/dist/ExperimentPopover-oL0fg_A0.js.map +1 -0
- package/dist/{ExperimentSelectorModal-DvIagrxY.js → ExperimentSelectorModal-BeEOomw_.js} +4 -4
- package/dist/ExperimentSelectorModal-BeEOomw_.js.map +1 -0
- package/dist/{SettingsModal-W3TYfnP9.js → SettingsModal-Dphh3RrI.js} +4 -4
- package/dist/SettingsModal-Dphh3RrI.js.map +1 -0
- package/dist/{EmptyState-NBqf2_8z.js → Skeleton-DxtwJ5gt.js} +110 -92
- package/dist/Skeleton-DxtwJ5gt.js.map +1 -0
- package/dist/__tests__/components/ExperimentTimeline.test.d.ts +1 -0
- package/dist/__tests__/composables/useListReorder.test.d.ts +1 -0
- package/dist/__tests__/styles/cssTokens.test.d.ts +1 -0
- package/dist/components/ActionMenu.vue.d.ts +1 -1
- package/dist/components/AppPluginSwitcher.vue.d.ts +2 -0
- package/dist/components/AuditTrail.vue.d.ts +13 -0
- package/dist/components/BatchProgressList.vue.d.ts +17 -0
- package/dist/components/DataFrame.vue.d.ts +14 -2
- package/dist/components/DatePicker.vue.d.ts +1 -1
- package/dist/components/DateTimePicker.vue.d.ts +2 -0
- package/dist/components/ExperimentDataViewer.vue.d.ts +26 -1
- package/dist/components/ExperimentPopover.vue.d.ts +1 -0
- package/dist/components/ExperimentTimeline.vue.d.ts +29 -1
- package/dist/components/LcmsSequenceTable.vue.d.ts +29 -1
- package/dist/components/ProgressBar.vue.d.ts +1 -1
- package/dist/components/ProtocolStepEditor.vue.d.ts +1 -0
- package/dist/components/ReagentList.vue.d.ts +29 -1
- package/dist/components/SampleHierarchyTree.vue.d.ts +29 -1
- package/dist/components/SampleSelector.drag.d.ts +13 -0
- package/dist/components/SampleSelector.tree.d.ts +2 -0
- package/dist/components/SampleSelector.vue.d.ts +34 -1
- package/dist/components/ScheduleCalendar.vue.d.ts +20 -0
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +1 -0
- package/dist/components/TimePicker.vue.d.ts +1 -0
- package/dist/components/WellPlate.geometry.d.ts +15 -0
- package/dist/components/WellPlate.interaction.d.ts +13 -0
- package/dist/components/WellPlate.vue.d.ts +29 -0
- package/dist/components/index.js +6 -6
- package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -0
- package/dist/components/internal/DataStateInternal.vue.d.ts +43 -0
- package/dist/{components-B80gV0P6.js → components-CgAUzF8F.js} +1930 -1688
- package/dist/components-CgAUzF8F.js.map +1 -0
- package/dist/composables/controlSchemaTypes.d.ts +34 -0
- package/dist/composables/index.d.ts +1 -0
- package/dist/composables/index.js +9 -9
- package/dist/composables/useDropdownState.d.ts +18 -2
- package/dist/composables/useEventStream.d.ts +9 -0
- package/dist/composables/useListReorder.d.ts +90 -0
- package/dist/composables/useMenuKeyboard.d.ts +2 -2
- package/dist/composables/usePluginConfig.d.ts +3 -1
- package/dist/composables/useRequestSyncState.d.ts +42 -2
- package/dist/composables/useScheduleDrag.d.ts +1 -0
- package/dist/{composables-C8mmD5ta.js → composables-B_pia3hO.js} +13 -69
- package/dist/composables-B_pia3hO.js.map +1 -0
- package/dist/{useApi-CDBq6z23.js → errors-_W0-LbA6.js} +102 -3
- package/dist/errors-_W0-LbA6.js.map +1 -0
- package/dist/{experiment-Y9Iz7KYi.js → experiment-Cwl9AYxw.js} +3 -8
- package/dist/experiment-Cwl9AYxw.js.map +1 -0
- package/dist/experiment-utils-DV3DrvE_.js.map +1 -1
- package/dist/index.js +14 -14
- package/dist/install.js +2 -7
- package/dist/install.js.map +1 -1
- package/dist/permissions.d.ts +42 -1
- package/dist/permissions.js +20 -10
- package/dist/permissions.js.map +1 -1
- package/dist/stores/index.d.ts +1 -1
- package/dist/stores/index.js +3 -3
- package/dist/stores/settings.d.ts +31 -1
- package/dist/styles.css +9572 -27877
- package/dist/templates/index.js +2 -4
- package/dist/{templates-CK72aKO1.js → templates-Qq113pyZ.js} +291 -85
- package/dist/templates-Qq113pyZ.js.map +1 -0
- package/dist/{useControlSchema-_OVhavKq.js → useControlSchema-aMZlb4Sc.js} +197 -1089
- package/dist/useControlSchema-aMZlb4Sc.js.map +1 -0
- package/dist/{useDropdownState-D5G4UvWd.js → useDropdownState-DitCtrTT.js} +21 -5
- package/dist/useDropdownState-DitCtrTT.js.map +1 -0
- package/dist/{useExperimentSelector-DSEckf_g.js → useExperimentSelector-I8HC8hUu.js} +3 -3
- package/dist/{useExperimentSelector-DSEckf_g.js.map → useExperimentSelector-I8HC8hUu.js.map} +1 -1
- package/dist/{useFormBuilder-Bd714C7y.js → useFormBuilder-TCxNnqeb.js} +2 -2
- package/dist/{useFormBuilder-Bd714C7y.js.map → useFormBuilder-TCxNnqeb.js.map} +1 -1
- package/dist/{useProtocolTemplates-Dq5Pzwyd.js → useProtocolTemplates-BmX_iVBR.js} +185 -157
- package/dist/useProtocolTemplates-BmX_iVBR.js.map +1 -0
- package/dist/utils/color.d.ts +13 -0
- package/dist/utils/errors.d.ts +9 -0
- package/package.json +1 -1
- package/src/__tests__/components/ActionMenu.test.ts +94 -0
- package/src/__tests__/components/DataFrame.test.ts +183 -11
- package/src/__tests__/components/DataStates.test.ts +176 -0
- package/src/__tests__/components/DatePicker.test.ts +76 -0
- package/src/__tests__/components/DateTimePicker.test.ts +36 -0
- package/src/__tests__/components/ExperimentTimeline.test.ts +63 -0
- package/src/__tests__/components/GroupAssigner.test.ts +53 -0
- package/src/__tests__/components/LcmsSequenceTable.test.ts +22 -0
- package/src/__tests__/components/RackEditor.test.ts +35 -0
- package/src/__tests__/components/ReagentList.test.ts +64 -2
- package/src/__tests__/components/RetiredStyles.test.ts +31 -0
- package/src/__tests__/components/SampleSelector.drag.test.ts +51 -1
- package/src/__tests__/components/ScheduleCalendar.test.ts +63 -0
- package/src/__tests__/components/SegmentedControl.test.ts +88 -0
- package/src/__tests__/components/TimePicker.test.ts +61 -0
- package/src/__tests__/components/WellPlate.interaction.test.ts +59 -0
- package/src/__tests__/composables/useDropdownState.test.ts +58 -1
- package/src/__tests__/composables/useListReorder.test.ts +145 -0
- package/src/__tests__/composables/useRequestSyncState.test.ts +140 -0
- package/src/__tests__/stores/settings.test.ts +71 -1
- package/src/__tests__/styles/cssTokens.test.ts +131 -0
- package/src/__tests__/utils/permissions.test.ts +68 -0
- package/src/components/ActionMenu.vue +45 -88
- package/src/components/AlertBox.vue +0 -4
- package/src/components/AppAvatarMenu.vue +2 -14
- package/src/components/AppLayout.vue +0 -4
- package/src/components/AppPluginSwitcher.story.vue +87 -0
- package/src/components/AppPluginSwitcher.vue +2 -5
- package/src/components/AppSidebar.vue +0 -4
- package/src/components/AppToastContainer.vue +6 -8
- package/src/components/AppTopBar.vue +0 -4
- package/src/components/ArtifactSaveDialog.vue +0 -4
- package/src/components/ArtifactSelectorModal.vue +0 -4
- package/src/components/AuditTrail.story.vue +18 -0
- package/src/components/AuditTrail.vue +27 -7
- package/src/components/AutoGroupModal.vue +0 -4
- package/src/components/Avatar.vue +0 -4
- package/src/components/BaseButton.vue +0 -4
- package/src/components/BaseCheckbox.vue +0 -4
- package/src/components/BaseInput.vue +0 -4
- package/src/components/BaseModal.vue +0 -4
- package/src/components/BasePill.vue +0 -4
- package/src/components/BaseRadioGroup.vue +0 -4
- package/src/components/BaseSelect.vue +0 -4
- package/src/components/BaseSlider.vue +0 -4
- package/src/components/BaseTabs.vue +0 -4
- package/src/components/BaseTextarea.vue +0 -4
- package/src/components/BaseToggle.vue +0 -4
- package/src/components/BatchProgressList.story.vue +18 -0
- package/src/components/BatchProgressList.vue +32 -4
- package/src/components/Breadcrumb.vue +0 -4
- package/src/components/Calendar.vue +0 -4
- package/src/components/ChartContainer.story.vue +1 -1
- package/src/components/ChartContainer.vue +0 -4
- package/src/components/ChemicalFormula.vue +0 -4
- package/src/components/CollapsibleCard.vue +0 -4
- package/src/components/ColorSlider.vue +0 -4
- package/src/components/ConcentrationInput.vue +0 -4
- package/src/components/ConfirmDialog.vue +0 -4
- package/src/components/DataFrame.story.vue +14 -0
- package/src/components/DataFrame.vue +97 -23
- package/src/components/DatePicker.vue +73 -27
- package/src/components/DateTimePicker.vue +45 -17
- package/src/components/Divider.vue +0 -4
- package/src/components/DoseCalculator.vue +0 -4
- package/src/components/DropdownButton.vue +2 -14
- package/src/components/EmptyState.vue +0 -4
- package/src/components/ExperimentCodeBadge.vue +0 -4
- package/src/components/ExperimentDataViewer.story.vue +3 -0
- package/src/components/ExperimentDataViewer.vue +33 -13
- package/src/components/ExperimentPopover.vue +2 -5
- package/src/components/ExperimentSelectorModal.vue +0 -4
- package/src/components/ExperimentTimeline.story.vue +18 -0
- package/src/components/ExperimentTimeline.vue +81 -75
- package/src/components/FileUploader.vue +0 -4
- package/src/components/FitPanel.vue +0 -4
- package/src/components/FormActions.story.vue +96 -0
- package/src/components/FormActions.vue +0 -4
- package/src/components/FormBuilder.vue +0 -4
- package/src/components/FormField.vue +0 -4
- package/src/components/FormulaInput.vue +0 -4
- package/src/components/GeneratedPathInput.story.vue +68 -0
- package/src/components/GeneratedPlotlyResult.story.vue +96 -0
- package/src/components/GeneratedPlotlyResult.vue +3 -3
- package/src/components/GeneratedPluginView.story.vue +145 -0
- package/src/components/GeneratedPluginView.vue +10 -10
- package/src/components/GeneratedResultRenderer.story.vue +117 -0
- package/src/components/GeneratedResultRenderer.vue +4 -4
- package/src/components/GroupAssigner.vue +67 -5
- package/src/components/IconButton.vue +0 -4
- package/src/components/InstrumentAlertLog.vue +0 -4
- package/src/components/InstrumentStateBadge.vue +0 -4
- package/src/components/InstrumentStatusCard.vue +0 -4
- package/src/components/JobsStatusTray.vue +0 -4
- package/src/components/LcmsSequenceTable.vue +81 -50
- package/src/components/LoadingSpinner.vue +0 -4
- package/src/components/MobileSupportGate.vue +0 -4
- package/src/components/MoleculeInput.vue +0 -4
- package/src/components/MultiSelect.vue +0 -4
- package/src/components/NumberInput.vue +0 -4
- package/src/components/PlateMapEditor.vue +3 -14
- package/src/components/PluginIcon.vue +0 -4
- package/src/components/ProgressBar.vue +0 -4
- package/src/components/ProtocolStepEditor.vue +2 -4
- package/src/components/RackEditor.vue +51 -70
- package/src/components/ReagentEditor.vue +0 -4
- package/src/components/ReagentList.story.vue +18 -0
- package/src/components/ReagentList.vue +127 -82
- package/src/components/ResourceCard.vue +0 -4
- package/src/components/RuntimeUpdateNotice.story.vue +49 -0
- package/src/components/RuntimeUpdateNotice.vue +4 -4
- package/src/components/SampleHierarchyTree.story.vue +18 -0
- package/src/components/SampleHierarchyTree.vue +32 -9
- package/src/components/SampleLegend.vue +2 -8
- package/src/components/SampleSelector.drag.ts +51 -0
- package/src/components/SampleSelector.story.vue +6 -0
- package/src/components/SampleSelector.tree.ts +2 -0
- package/src/components/SampleSelector.vue +49 -10
- package/src/components/SampleSelectorGroupNode.vue +19 -0
- package/src/components/ScheduleCalendar.story.vue +6 -0
- package/src/components/ScheduleCalendar.vue +82 -7
- package/src/components/ScientificNumber.vue +0 -4
- package/src/components/SectionCard.vue +0 -4
- package/src/components/SegmentedControl.vue +48 -5
- package/src/components/SequenceInput.vue +0 -4
- package/src/components/SequenceProgressBar.story.vue +110 -0
- package/src/components/SequenceProgressBar.vue +0 -4
- package/src/components/SettingsModal.vue +0 -4
- package/src/components/Skeleton.vue +0 -4
- package/src/components/SmartGroupFieldRecipe.vue +8 -6
- package/src/components/SmartGroupManual.vue +0 -4
- package/src/components/StatusIndicator.vue +0 -4
- package/src/components/StepWizard.vue +0 -4
- package/src/components/TagsInput.vue +4 -6
- package/src/components/ThemeToggle.vue +0 -4
- package/src/components/TimePicker.vue +36 -7
- package/src/components/TimeRangeInput.vue +0 -4
- package/src/components/Tooltip.story.vue +3 -3
- package/src/components/Tooltip.vue +0 -4
- package/src/components/UnitInput.vue +0 -4
- package/src/components/WellPlate.geometry.ts +60 -1
- package/src/components/WellPlate.interaction.ts +44 -0
- package/src/components/WellPlate.story.vue +8 -0
- package/src/components/WellPlate.vue +53 -7
- package/src/components/internal/AppTopBarPageSelectorInternal.vue +2 -5
- package/src/components/internal/AppTopBarPillNavInternal.vue +0 -4
- package/src/components/internal/DataStateInternal.vue +74 -0
- package/src/components/internal/FormSectionRenderer.vue +0 -4
- package/src/components/internal/WellEditPopupInternal.vue +0 -4
- package/src/composables/controlSchemaTypes.ts +34 -0
- package/src/composables/index.ts +10 -0
- package/src/composables/useDropdownState.ts +35 -5
- package/src/composables/useEventStream.ts +14 -2
- package/src/composables/useFileImport.ts +2 -7
- package/src/composables/useGeneratedAnalysis.ts +8 -9
- package/src/composables/useListReorder.story.vue +143 -0
- package/src/composables/useListReorder.ts +242 -0
- package/src/composables/useMenuKeyboard.ts +2 -2
- package/src/composables/useOptimisticMutation.ts +1 -15
- package/src/composables/usePluginConfig.ts +3 -1
- package/src/composables/useRackEditor.ts +2 -11
- package/src/composables/useRequestSyncState.ts +98 -28
- package/src/composables/useResourceCrud.ts +1 -15
- package/src/composables/useScheduleDrag.ts +37 -0
- package/src/composables/useWellPainting.ts +5 -29
- package/src/composables/useWellPlateEditor.ts +1 -11
- package/src/permissions.ts +48 -9
- package/src/stores/experiment.ts +2 -7
- package/src/stores/index.ts +9 -1
- package/src/stores/settings.ts +93 -1
- package/src/styles/components/audit-trail.css +5 -5
- package/src/styles/components/batch-progress-list.css +7 -0
- package/src/styles/components/color-slider.css +2 -2
- package/src/styles/components/data-state.css +30 -0
- package/src/styles/components/dataframe.css +29 -35
- package/src/styles/components/experiment-data-viewer.css +4 -4
- package/src/styles/components/experiment-timeline.css +3 -30
- package/src/styles/components/form-builder.css +5 -5
- package/src/styles/components/lcms-sequence-table.css +16 -1
- package/src/styles/components/multi-select.css +1 -1
- package/src/styles/components/reagent-list.css +38 -31
- package/src/styles/components/sample-hierarchy-tree.css +5 -19
- package/src/styles/components/sample-selector.css +9 -2
- package/src/styles/components/schedule-calendar.css +8 -0
- package/src/styles/components/scientific-number.css +1 -1
- package/src/styles/components/well-plate.css +8 -0
- package/src/styles/index.css +8 -0
- package/src/styles/variables.css +29 -0
- package/src/templates/builderPresetControls.ts +1 -1
- package/src/templates/componentPlateHelpers.ts +3 -1
- package/src/templates/doseResponseAdapters.ts +2 -13
- package/src/templates/elisaAssayCollectionBuilder.ts +2 -2
- package/src/templates/plateMapBuilder.ts +2 -2
- package/src/templates/qpcrPlateBuilder.ts +1 -1
- package/src/templates/templateValidators.ts +1 -1
- package/src/templates/timeCourseBuilder.ts +2 -2
- package/src/templates/wellPlateScreenCollectionBuilder.ts +2 -2
- package/src/utils/color.ts +51 -0
- package/src/utils/errors.ts +29 -0
- package/dist/EmptyState-NBqf2_8z.js.map +0 -1
- package/dist/ExperimentPopover-CfOdh5Rm.js.map +0 -1
- package/dist/ExperimentSelectorModal-DvIagrxY.js.map +0 -1
- package/dist/SettingsModal-W3TYfnP9.js.map +0 -1
- package/dist/components-B80gV0P6.js.map +0 -1
- package/dist/composables/autoGroupConstants.d.ts +0 -1
- package/dist/composables/autoGroupGrouping.d.ts +0 -3
- package/dist/composables/useAutoGroupInputSources.d.ts +0 -32
- package/dist/composables-C8mmD5ta.js.map +0 -1
- package/dist/experiment-Y9Iz7KYi.js.map +0 -1
- package/dist/templates/builderDefaults.d.ts +0 -1
- package/dist/templates/plateGeometry.d.ts +0 -4
- package/dist/templates-CK72aKO1.js.map +0 -1
- package/dist/useApi-CDBq6z23.js.map +0 -1
- package/dist/useControlSchema-_OVhavKq.js.map +0 -1
- package/dist/useDropdownState-D5G4UvWd.js.map +0 -1
- package/dist/useProtocolTemplates-Dq5Pzwyd.js.map +0 -1
- package/src/__tests__/composables/useAutoGroupInputSources.test.ts +0 -127
- package/src/composables/autoGroupConstants.ts +0 -4
- package/src/composables/autoGroupGrouping.ts +0 -148
- package/src/composables/useAutoGroupInputSources.ts +0 -147
- package/src/templates/builderDefaults.ts +0 -11
- package/src/templates/plateGeometry.ts +0 -62
- /package/dist/__tests__/{composables/useAutoGroupInputSources.test.d.ts → components/DataStates.test.d.ts} +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { nextTick } from 'vue'
|
|
2
3
|
import {
|
|
3
4
|
nextClickedWellSelection,
|
|
4
5
|
useWellPlateInteraction,
|
|
@@ -41,6 +42,14 @@ function dragEvent(data: Record<string, string> = {}): DragEvent {
|
|
|
41
42
|
} as unknown as DragEvent
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
function keyEvent(key: string, altKey = true): KeyboardEvent {
|
|
46
|
+
return {
|
|
47
|
+
key,
|
|
48
|
+
altKey,
|
|
49
|
+
preventDefault: vi.fn(),
|
|
50
|
+
} as unknown as KeyboardEvent
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
function createInteraction(overrides: Partial<Parameters<typeof useWellPlateInteraction>[0]> = {}) {
|
|
45
54
|
const emitted: Record<string, unknown[]> = {}
|
|
46
55
|
const selected = { value: [] as string[] }
|
|
@@ -168,4 +177,54 @@ describe('WellPlate interaction helpers', () => {
|
|
|
168
177
|
['A1', 'A2', 'B1', 'B2'],
|
|
169
178
|
])
|
|
170
179
|
})
|
|
180
|
+
|
|
181
|
+
it('moves a well with the keyboard pick-up/drop chord in drag mode', async () => {
|
|
182
|
+
const { emitted, interaction, selectionMode } = createInteraction()
|
|
183
|
+
selectionMode.value = 'drag'
|
|
184
|
+
const source = well({ id: 'A1', row: 0, col: 0, sampleType: 'sample' })
|
|
185
|
+
const target = well({ id: 'B2', row: 1, col: 1 })
|
|
186
|
+
|
|
187
|
+
interaction.handleWellMoveKeydown(source, keyEvent('Enter'))
|
|
188
|
+
expect(interaction.dragSourceWell.value).toBe('A1')
|
|
189
|
+
|
|
190
|
+
interaction.handleWellMoveKeydown(target, keyEvent('Enter'))
|
|
191
|
+
|
|
192
|
+
expect(emitted.move).toEqual([['A1', 'B2']])
|
|
193
|
+
expect(interaction.dragSourceWell.value).toBeNull()
|
|
194
|
+
|
|
195
|
+
await nextTick()
|
|
196
|
+
expect(interaction.moveAnnouncement.value).toBe('Well A1 moved to B2.')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('cancels a keyboard pick-up with Escape and refuses empty wells', () => {
|
|
200
|
+
const { emitted, interaction, selectionMode } = createInteraction()
|
|
201
|
+
selectionMode.value = 'drag'
|
|
202
|
+
|
|
203
|
+
interaction.handleWellMoveKeydown(well({ id: 'A2', row: 0, col: 1 }), keyEvent('Enter'))
|
|
204
|
+
expect(interaction.dragSourceWell.value).toBeNull()
|
|
205
|
+
|
|
206
|
+
interaction.handleWellMoveKeydown(
|
|
207
|
+
well({ id: 'A1', row: 0, col: 0, sampleType: 'sample' }),
|
|
208
|
+
keyEvent('Enter'),
|
|
209
|
+
)
|
|
210
|
+
expect(interaction.dragSourceWell.value).toBe('A1')
|
|
211
|
+
|
|
212
|
+
interaction.handleWellMoveKeydown(
|
|
213
|
+
well({ id: 'A1', row: 0, col: 0, sampleType: 'sample' }),
|
|
214
|
+
keyEvent('Escape', false),
|
|
215
|
+
)
|
|
216
|
+
expect(interaction.dragSourceWell.value).toBeNull()
|
|
217
|
+
expect(emitted.move).toBeUndefined()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('ignores the move chord outside drag selection mode', () => {
|
|
221
|
+
const { interaction } = createInteraction()
|
|
222
|
+
|
|
223
|
+
interaction.handleWellMoveKeydown(
|
|
224
|
+
well({ id: 'A1', row: 0, col: 0, sampleType: 'sample' }),
|
|
225
|
+
keyEvent('Enter'),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
expect(interaction.dragSourceWell.value).toBeNull()
|
|
229
|
+
})
|
|
171
230
|
})
|
|
@@ -14,7 +14,7 @@ function mountHarness(options: UseDropdownStateOptions = {}) {
|
|
|
14
14
|
state = useDropdownState(options)
|
|
15
15
|
return state
|
|
16
16
|
},
|
|
17
|
-
template: '<div ref="rootRef" class="dropdown-root"><button class="inside">Inside</button></div>',
|
|
17
|
+
template: '<div ref="rootRef" class="dropdown-root"><button ref="triggerRef" class="trigger">Trigger</button><button class="inside">Inside</button></div>',
|
|
18
18
|
}), { attachTo: document.body })
|
|
19
19
|
|
|
20
20
|
if (!state) {
|
|
@@ -78,6 +78,63 @@ describe('useDropdownState', () => {
|
|
|
78
78
|
wrapper.unmount()
|
|
79
79
|
})
|
|
80
80
|
|
|
81
|
+
it('returns focus to the trigger only when asked', async () => {
|
|
82
|
+
const { wrapper, state } = mountHarness()
|
|
83
|
+
const trigger = wrapper.get<HTMLButtonElement>('.trigger').element
|
|
84
|
+
const inside = wrapper.get<HTMLButtonElement>('.inside').element
|
|
85
|
+
|
|
86
|
+
state.open()
|
|
87
|
+
inside.focus()
|
|
88
|
+
state.close()
|
|
89
|
+
await nextTick()
|
|
90
|
+
expect(document.activeElement).toBe(inside)
|
|
91
|
+
|
|
92
|
+
state.open()
|
|
93
|
+
inside.focus()
|
|
94
|
+
state.close(true)
|
|
95
|
+
await nextTick()
|
|
96
|
+
expect(document.activeElement).toBe(trigger)
|
|
97
|
+
|
|
98
|
+
wrapper.unmount()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('returns focus to the trigger when Escape closes the dropdown', async () => {
|
|
102
|
+
const { wrapper, state } = mountHarness()
|
|
103
|
+
const trigger = wrapper.get<HTMLButtonElement>('.trigger').element
|
|
104
|
+
|
|
105
|
+
state.open()
|
|
106
|
+
wrapper.get<HTMLButtonElement>('.inside').element.focus()
|
|
107
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
|
108
|
+
await nextTick()
|
|
109
|
+
await nextTick()
|
|
110
|
+
|
|
111
|
+
expect(state.isOpen.value).toBe(false)
|
|
112
|
+
expect(document.activeElement).toBe(trigger)
|
|
113
|
+
|
|
114
|
+
wrapper.unmount()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('closes on scroll and resize only when closeOnScroll is set', async () => {
|
|
118
|
+
const plain = mountHarness()
|
|
119
|
+
plain.state.open()
|
|
120
|
+
window.dispatchEvent(new Event('scroll'))
|
|
121
|
+
await nextTick()
|
|
122
|
+
expect(plain.state.isOpen.value).toBe(true)
|
|
123
|
+
plain.wrapper.unmount()
|
|
124
|
+
|
|
125
|
+
const scrolling = mountHarness({ closeOnScroll: true })
|
|
126
|
+
scrolling.state.open()
|
|
127
|
+
window.dispatchEvent(new Event('scroll'))
|
|
128
|
+
await nextTick()
|
|
129
|
+
expect(scrolling.state.isOpen.value).toBe(false)
|
|
130
|
+
|
|
131
|
+
scrolling.state.open()
|
|
132
|
+
window.dispatchEvent(new Event('resize'))
|
|
133
|
+
await nextTick()
|
|
134
|
+
expect(scrolling.state.isOpen.value).toBe(false)
|
|
135
|
+
scrolling.wrapper.unmount()
|
|
136
|
+
})
|
|
137
|
+
|
|
81
138
|
it('can opt out of outside-click and Escape closing', async () => {
|
|
82
139
|
const { wrapper, state } = mountHarness({
|
|
83
140
|
closeOnOutsideClick: false,
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { defineComponent, nextTick, ref } from 'vue'
|
|
4
|
+
import {
|
|
5
|
+
reorderList,
|
|
6
|
+
useListReorder,
|
|
7
|
+
useReorderAnnouncer,
|
|
8
|
+
type ListReorderMove,
|
|
9
|
+
} from '../../composables/useListReorder'
|
|
10
|
+
|
|
11
|
+
interface Row { id: string; name: string }
|
|
12
|
+
|
|
13
|
+
function mountHarness(onReorder: (move: ListReorderMove<Row>) => void, disabled = false) {
|
|
14
|
+
const items = ref<Row[]>([
|
|
15
|
+
{ id: 'a', name: 'Alpha' },
|
|
16
|
+
{ id: 'b', name: 'Beta' },
|
|
17
|
+
{ id: 'c', name: 'Gamma' },
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
const wrapper = mount(defineComponent({
|
|
21
|
+
setup() {
|
|
22
|
+
const reorder = useListReorder<Row>({
|
|
23
|
+
items: () => items.value,
|
|
24
|
+
getId: (row) => row.id,
|
|
25
|
+
getLabel: (row) => row.name,
|
|
26
|
+
disabled: () => disabled,
|
|
27
|
+
itemNoun: 'Row',
|
|
28
|
+
onReorder,
|
|
29
|
+
})
|
|
30
|
+
return { items, ...reorder }
|
|
31
|
+
},
|
|
32
|
+
template: `
|
|
33
|
+
<ul>
|
|
34
|
+
<li v-for="(row, index) in items" :key="row.id" :data-id="row.id"
|
|
35
|
+
@keydown="onKeydown($event, row, index)"
|
|
36
|
+
@dragstart="onDragStart($event, row, index)"
|
|
37
|
+
@dragover="onDragOver($event, row, index)"
|
|
38
|
+
@drop="onDrop($event, row, index)">{{ row.name }}</li>
|
|
39
|
+
</ul>
|
|
40
|
+
<p class="announcement">{{ announcement }}</p>
|
|
41
|
+
`,
|
|
42
|
+
}), { attachTo: document.body })
|
|
43
|
+
|
|
44
|
+
return { wrapper, items }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('reorderList', () => {
|
|
48
|
+
it('returns a copy with the item moved, leaving the source untouched', () => {
|
|
49
|
+
const source = ['a', 'b', 'c']
|
|
50
|
+
|
|
51
|
+
expect(reorderList(source, 0, 2)).toEqual(['b', 'c', 'a'])
|
|
52
|
+
expect(source).toEqual(['a', 'b', 'c'])
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('useReorderAnnouncer', () => {
|
|
57
|
+
it('publishes messages and re-announces an identical message', async () => {
|
|
58
|
+
const { announcement, announce } = useReorderAnnouncer()
|
|
59
|
+
|
|
60
|
+
announce('Alpha moved.')
|
|
61
|
+
await nextTick()
|
|
62
|
+
expect(announcement.value).toBe('Alpha moved.')
|
|
63
|
+
|
|
64
|
+
announce('Alpha moved.')
|
|
65
|
+
expect(announcement.value).toBe('')
|
|
66
|
+
await nextTick()
|
|
67
|
+
expect(announcement.value).toBe('Alpha moved.')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('useListReorder', () => {
|
|
72
|
+
it('moves an item one position later on Alt+ArrowDown and announces it', async () => {
|
|
73
|
+
const onReorder = vi.fn()
|
|
74
|
+
const { wrapper } = mountHarness(onReorder)
|
|
75
|
+
|
|
76
|
+
await wrapper.get('[data-id="a"]').trigger('keydown', { key: 'ArrowDown', altKey: true })
|
|
77
|
+
|
|
78
|
+
expect(onReorder).toHaveBeenCalledTimes(1)
|
|
79
|
+
const move = onReorder.mock.calls[0][0] as ListReorderMove<Row>
|
|
80
|
+
expect(move.fromIndex).toBe(0)
|
|
81
|
+
expect(move.toIndex).toBe(1)
|
|
82
|
+
expect(move.source).toBe('keyboard')
|
|
83
|
+
expect(move.items.map(row => row.id)).toEqual(['b', 'a', 'c'])
|
|
84
|
+
|
|
85
|
+
await nextTick()
|
|
86
|
+
expect(wrapper.get('.announcement').text()).toBe('Alpha moved to position 2 of 3.')
|
|
87
|
+
|
|
88
|
+
wrapper.unmount()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('moves an item one position earlier on Alt+ArrowUp and Alt+ArrowLeft', async () => {
|
|
92
|
+
const onReorder = vi.fn()
|
|
93
|
+
const { wrapper } = mountHarness(onReorder)
|
|
94
|
+
|
|
95
|
+
await wrapper.get('[data-id="c"]').trigger('keydown', { key: 'ArrowUp', altKey: true })
|
|
96
|
+
await wrapper.get('[data-id="c"]').trigger('keydown', { key: 'ArrowLeft', altKey: true })
|
|
97
|
+
|
|
98
|
+
expect(onReorder.mock.calls.map(call => (call[0] as ListReorderMove<Row>).toIndex)).toEqual([1, 1])
|
|
99
|
+
|
|
100
|
+
wrapper.unmount()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('ignores arrow keys without Alt and moves past either end', async () => {
|
|
104
|
+
const onReorder = vi.fn()
|
|
105
|
+
const { wrapper } = mountHarness(onReorder)
|
|
106
|
+
|
|
107
|
+
await wrapper.get('[data-id="a"]').trigger('keydown', { key: 'ArrowDown' })
|
|
108
|
+
await wrapper.get('[data-id="a"]').trigger('keydown', { key: 'ArrowUp', altKey: true })
|
|
109
|
+
await wrapper.get('[data-id="c"]').trigger('keydown', { key: 'ArrowDown', altKey: true })
|
|
110
|
+
|
|
111
|
+
expect(onReorder).not.toHaveBeenCalled()
|
|
112
|
+
|
|
113
|
+
wrapper.unmount()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('reorders through a drag and clears its drag state', async () => {
|
|
117
|
+
const onReorder = vi.fn()
|
|
118
|
+
const { wrapper } = mountHarness(onReorder)
|
|
119
|
+
|
|
120
|
+
const dataTransfer = { effectAllowed: '', dropEffect: '', setData: vi.fn() }
|
|
121
|
+
await wrapper.get('[data-id="a"]').trigger('dragstart', { dataTransfer })
|
|
122
|
+
await wrapper.get('[data-id="c"]').trigger('dragover', { dataTransfer })
|
|
123
|
+
await wrapper.get('[data-id="c"]').trigger('drop', { dataTransfer })
|
|
124
|
+
|
|
125
|
+
const move = onReorder.mock.calls[0][0] as ListReorderMove<Row>
|
|
126
|
+
expect(move).toMatchObject({ fromIndex: 0, toIndex: 2, source: 'drag' })
|
|
127
|
+
expect(move.items.map(row => row.id)).toEqual(['b', 'c', 'a'])
|
|
128
|
+
expect(dataTransfer.setData).toHaveBeenCalledWith('text/plain', 'a')
|
|
129
|
+
|
|
130
|
+
wrapper.unmount()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('blocks every reorder while disabled', async () => {
|
|
134
|
+
const onReorder = vi.fn()
|
|
135
|
+
const { wrapper } = mountHarness(onReorder, true)
|
|
136
|
+
|
|
137
|
+
await wrapper.get('[data-id="a"]').trigger('keydown', { key: 'ArrowDown', altKey: true })
|
|
138
|
+
await wrapper.get('[data-id="a"]').trigger('dragstart', { dataTransfer: { setData: vi.fn() } })
|
|
139
|
+
await wrapper.get('[data-id="b"]').trigger('drop', { dataTransfer: { setData: vi.fn() } })
|
|
140
|
+
|
|
141
|
+
expect(onReorder).not.toHaveBeenCalled()
|
|
142
|
+
|
|
143
|
+
wrapper.unmount()
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -89,4 +89,144 @@ describe('useRequestSyncState', () => {
|
|
|
89
89
|
expect(state.lastSavedAt.value).toBeNull()
|
|
90
90
|
expect(state.lastRunAt.value).toBeNull()
|
|
91
91
|
})
|
|
92
|
+
|
|
93
|
+
describe('stale-response guard', () => {
|
|
94
|
+
function deferred<T>() {
|
|
95
|
+
let resolve!: (value: T) => void
|
|
96
|
+
let reject!: (reason: unknown) => void
|
|
97
|
+
const promise = new Promise<T>((res, rej) => {
|
|
98
|
+
resolve = res
|
|
99
|
+
reject = rej
|
|
100
|
+
})
|
|
101
|
+
return { promise, resolve, reject }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
it('lets only the newest run own loading', async () => {
|
|
105
|
+
const state = useRequestSyncState()
|
|
106
|
+
const first = deferred<string>()
|
|
107
|
+
const second = deferred<string>()
|
|
108
|
+
|
|
109
|
+
const firstRun = state.run(() => first.promise, { success: 'load' })
|
|
110
|
+
const secondRun = state.run(() => second.promise, { success: 'load' })
|
|
111
|
+
|
|
112
|
+
first.resolve('stale')
|
|
113
|
+
await expect(firstRun).resolves.toBe('stale')
|
|
114
|
+
expect(state.loading.value).toBe(true)
|
|
115
|
+
|
|
116
|
+
second.resolve('fresh')
|
|
117
|
+
await expect(secondRun).resolves.toBe('fresh')
|
|
118
|
+
expect(state.loading.value).toBe(false)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('does not let a stale success move the sync timestamps', async () => {
|
|
122
|
+
const state = useRequestSyncState()
|
|
123
|
+
const first = deferred<string>()
|
|
124
|
+
const second = deferred<string>()
|
|
125
|
+
|
|
126
|
+
const firstRun = state.run(() => first.promise, { success: 'save' })
|
|
127
|
+
const secondRun = state.run(() => second.promise, { success: 'load' })
|
|
128
|
+
|
|
129
|
+
first.resolve('stale')
|
|
130
|
+
await firstRun
|
|
131
|
+
expect(state.lastSavedAt.value).toBeNull()
|
|
132
|
+
|
|
133
|
+
second.resolve('fresh')
|
|
134
|
+
await secondRun
|
|
135
|
+
expect(state.lastLoadedAt.value).toBeInstanceOf(Date)
|
|
136
|
+
expect(state.lastSavedAt.value).toBeNull()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('does not let a stale failure write the error, but still rejects its caller', async () => {
|
|
140
|
+
const state = useRequestSyncState()
|
|
141
|
+
const first = deferred<string>()
|
|
142
|
+
const second = deferred<string>()
|
|
143
|
+
|
|
144
|
+
const firstRun = state.run(() => first.promise, { errorMessage: 'first failed' })
|
|
145
|
+
const secondRun = state.run(() => second.promise, { success: 'load' })
|
|
146
|
+
|
|
147
|
+
first.reject(new Error('stale failure'))
|
|
148
|
+
await expect(firstRun).rejects.toThrow('stale failure')
|
|
149
|
+
expect(state.error.value).toBeNull()
|
|
150
|
+
|
|
151
|
+
second.resolve('fresh')
|
|
152
|
+
await secondRun
|
|
153
|
+
expect(state.error.value).toBeNull()
|
|
154
|
+
expect(state.loading.value).toBe(false)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('aborts the superseded run signal by default', async () => {
|
|
158
|
+
const state = useRequestSyncState()
|
|
159
|
+
const first = deferred<string>()
|
|
160
|
+
const second = deferred<string>()
|
|
161
|
+
let firstSignal: AbortSignal | undefined
|
|
162
|
+
|
|
163
|
+
const firstRun = state.run((signal) => {
|
|
164
|
+
firstSignal = signal
|
|
165
|
+
return first.promise
|
|
166
|
+
})
|
|
167
|
+
expect(firstSignal?.aborted).toBe(false)
|
|
168
|
+
|
|
169
|
+
const secondRun = state.run(() => second.promise)
|
|
170
|
+
expect(firstSignal?.aborted).toBe(true)
|
|
171
|
+
|
|
172
|
+
first.resolve('stale')
|
|
173
|
+
second.resolve('fresh')
|
|
174
|
+
await Promise.all([firstRun, secondRun])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('leaves the previous signal alone when abortPrevious is false', async () => {
|
|
178
|
+
const state = useRequestSyncState()
|
|
179
|
+
const first = deferred<string>()
|
|
180
|
+
const second = deferred<string>()
|
|
181
|
+
let firstSignal: AbortSignal | undefined
|
|
182
|
+
|
|
183
|
+
const firstRun = state.run((signal) => {
|
|
184
|
+
firstSignal = signal
|
|
185
|
+
return first.promise
|
|
186
|
+
})
|
|
187
|
+
const secondRun = state.run(() => second.promise, { abortPrevious: false })
|
|
188
|
+
|
|
189
|
+
expect(firstSignal?.aborted).toBe(false)
|
|
190
|
+
first.resolve('stale')
|
|
191
|
+
second.resolve('fresh')
|
|
192
|
+
await Promise.all([firstRun, secondRun])
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('forwards a caller signal onto the operation signal', async () => {
|
|
196
|
+
const state = useRequestSyncState()
|
|
197
|
+
const caller = new AbortController()
|
|
198
|
+
const pending = deferred<string>()
|
|
199
|
+
let operationSignal: AbortSignal | undefined
|
|
200
|
+
|
|
201
|
+
const run = state.run((signal) => {
|
|
202
|
+
operationSignal = signal
|
|
203
|
+
return pending.promise
|
|
204
|
+
}, { signal: caller.signal })
|
|
205
|
+
|
|
206
|
+
caller.abort()
|
|
207
|
+
expect(operationSignal?.aborted).toBe(true)
|
|
208
|
+
|
|
209
|
+
pending.resolve('done')
|
|
210
|
+
await run
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('cancel() aborts the in-flight run and drops loading', async () => {
|
|
214
|
+
const state = useRequestSyncState()
|
|
215
|
+
const pending = deferred<string>()
|
|
216
|
+
let operationSignal: AbortSignal | undefined
|
|
217
|
+
|
|
218
|
+
const run = state.run((signal) => {
|
|
219
|
+
operationSignal = signal
|
|
220
|
+
return pending.promise
|
|
221
|
+
}, { success: 'load' })
|
|
222
|
+
|
|
223
|
+
state.cancel()
|
|
224
|
+
expect(operationSignal?.aborted).toBe(true)
|
|
225
|
+
expect(state.loading.value).toBe(false)
|
|
226
|
+
|
|
227
|
+
pending.resolve('too late')
|
|
228
|
+
await expect(run).resolves.toBe('too late')
|
|
229
|
+
expect(state.lastLoadedAt.value).toBeNull()
|
|
230
|
+
})
|
|
231
|
+
})
|
|
92
232
|
})
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import { createPinia, setActivePinia } from 'pinia'
|
|
3
|
-
import {
|
|
3
|
+
import { nextTick } from 'vue'
|
|
4
|
+
import {
|
|
5
|
+
paletteCssVariables,
|
|
6
|
+
tableDensityToSize,
|
|
7
|
+
tryUseSettingsStore,
|
|
8
|
+
useSettingsStore,
|
|
9
|
+
} from '../../stores/settings'
|
|
4
10
|
|
|
5
11
|
function mockMatchMedia(matches = false) {
|
|
6
12
|
Object.defineProperty(window, 'matchMedia', {
|
|
@@ -46,6 +52,8 @@ describe('useSettingsStore', () => {
|
|
|
46
52
|
setActivePinia(createPinia())
|
|
47
53
|
installLocalStorage()
|
|
48
54
|
document.documentElement.classList.remove('dark')
|
|
55
|
+
document.documentElement.removeAttribute('style')
|
|
56
|
+
delete document.documentElement.dataset.density
|
|
49
57
|
mockMatchMedia(false)
|
|
50
58
|
})
|
|
51
59
|
|
|
@@ -75,4 +83,66 @@ describe('useSettingsStore', () => {
|
|
|
75
83
|
tableDensity: 'compact',
|
|
76
84
|
}))
|
|
77
85
|
})
|
|
86
|
+
|
|
87
|
+
describe('palette and density DOM sync', () => {
|
|
88
|
+
it('writes no palette overrides for the default palette', () => {
|
|
89
|
+
const settings = useSettingsStore()
|
|
90
|
+
settings.initialize()
|
|
91
|
+
|
|
92
|
+
expect(paletteCssVariables('default')).toEqual({})
|
|
93
|
+
expect(document.documentElement.style.getPropertyValue('--color-primary')).toBe('')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('applies the stored palette hues to html on initialize', () => {
|
|
97
|
+
localStorage.setItem('mint-settings', JSON.stringify({ colorPalette: 'viridis' }))
|
|
98
|
+
|
|
99
|
+
const settings = useSettingsStore()
|
|
100
|
+
settings.initialize()
|
|
101
|
+
|
|
102
|
+
const style = document.documentElement.style
|
|
103
|
+
expect(style.getPropertyValue('--color-primary')).toBe('hsl(280 84% 67%)')
|
|
104
|
+
expect(style.getPropertyValue('--color-primary-hover')).toBe('hsl(280 75% 59%)')
|
|
105
|
+
expect(style.getPropertyValue('--color-cta')).toBe('hsl(80 95% 53%)')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('re-applies the overrides when the palette changes and clears them on default', async () => {
|
|
109
|
+
const settings = useSettingsStore()
|
|
110
|
+
settings.initialize()
|
|
111
|
+
|
|
112
|
+
settings.colorPalette = 'pastel'
|
|
113
|
+
await nextTick()
|
|
114
|
+
expect(document.documentElement.style.getPropertyValue('--color-primary')).toBe('hsl(200 84% 67%)')
|
|
115
|
+
|
|
116
|
+
settings.colorPalette = 'default'
|
|
117
|
+
await nextTick()
|
|
118
|
+
expect(document.documentElement.style.getPropertyValue('--color-primary')).toBe('')
|
|
119
|
+
expect(document.documentElement.style.getPropertyValue('--color-primary-soft')).toBe('')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('mirrors the table density onto html[data-density]', async () => {
|
|
123
|
+
const settings = useSettingsStore()
|
|
124
|
+
settings.initialize()
|
|
125
|
+
expect(document.documentElement.dataset.density).toBe('normal')
|
|
126
|
+
|
|
127
|
+
settings.tableDensity = 'compact'
|
|
128
|
+
await nextTick()
|
|
129
|
+
expect(document.documentElement.dataset.density).toBe('compact')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('maps each density onto a table size', () => {
|
|
133
|
+
const settings = useSettingsStore()
|
|
134
|
+
settings.initialize()
|
|
135
|
+
|
|
136
|
+
expect(tableDensityToSize('compact')).toBe('sm')
|
|
137
|
+
expect(tableDensityToSize('normal')).toBe('md')
|
|
138
|
+
expect(tableDensityToSize('comfortable')).toBe('lg')
|
|
139
|
+
|
|
140
|
+
settings.tableDensity = 'comfortable'
|
|
141
|
+
expect(settings.getTableSize()).toBe('lg')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('exposes the store through tryUseSettingsStore when Pinia is active', () => {
|
|
146
|
+
expect(tryUseSettingsStore()).not.toBeNull()
|
|
147
|
+
})
|
|
78
148
|
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const srcRoot = resolve(here, '../..')
|
|
8
|
+
const stylesDir = join(srcRoot, 'styles')
|
|
9
|
+
const variablesCss = readFileSync(join(stylesDir, 'variables.css'), 'utf-8')
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Custom properties that are legitimately *not* declared in variables.css:
|
|
13
|
+
* they are set at runtime (inline `:style` bindings, JS-built names) or are
|
|
14
|
+
* component-local knobs declared in the same stylesheet that consumes them.
|
|
15
|
+
* Anything else must be a canonical token from variables.css.
|
|
16
|
+
*/
|
|
17
|
+
const RUNTIME_PROPERTY_ALLOWLIST = new Set<string>([
|
|
18
|
+
// Names assembled in JS (`--grp-${i}`) — the literal prefix is what the
|
|
19
|
+
// scanner sees in a template string.
|
|
20
|
+
'--grp-',
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
const SOURCE_EXTENSIONS = ['.vue', '.css', '.ts']
|
|
24
|
+
const IGNORED_DIRS = new Set(['__tests__', 'generated-ui', 'node_modules'])
|
|
25
|
+
|
|
26
|
+
function walk(dir: string, out: string[] = []): string[] {
|
|
27
|
+
for (const entry of readdirSync(dir)) {
|
|
28
|
+
const full = join(dir, entry)
|
|
29
|
+
if (statSync(full).isDirectory()) {
|
|
30
|
+
if (!IGNORED_DIRS.has(entry)) walk(full, out)
|
|
31
|
+
} else if (SOURCE_EXTENSIONS.some((ext) => entry.endsWith(ext))) {
|
|
32
|
+
out.push(full)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const sourceFiles = walk(srcRoot)
|
|
39
|
+
|
|
40
|
+
function declaredIn(text: string): Set<string> {
|
|
41
|
+
const names = new Set<string>()
|
|
42
|
+
// `--foo: value` declarations, in CSS blocks and in JS style objects.
|
|
43
|
+
for (const m of text.matchAll(/(--[A-Za-z0-9_-]+)\s*:/g)) names.add(m[1])
|
|
44
|
+
// setProperty('--foo', …) / `'--foo'` keys in style bindings.
|
|
45
|
+
for (const m of text.matchAll(/['"`](--[A-Za-z0-9_-]+)['"`]/g)) names.add(m[1])
|
|
46
|
+
return names
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const canonicalTokens = declaredIn(variablesCss)
|
|
50
|
+
|
|
51
|
+
// Component-local custom properties: declared in a stylesheet or set from an
|
|
52
|
+
// inline `:style` binding in the SFC that owns it. They are legitimate, they
|
|
53
|
+
// just are not part of the global token set.
|
|
54
|
+
const componentLocalProperties = new Set<string>()
|
|
55
|
+
for (const file of sourceFiles) {
|
|
56
|
+
for (const name of declaredIn(readFileSync(file, 'utf-8'))) {
|
|
57
|
+
componentLocalProperties.add(name)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface Usage {
|
|
62
|
+
file: string
|
|
63
|
+
token: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const usages: Usage[] = []
|
|
67
|
+
for (const file of sourceFiles) {
|
|
68
|
+
const text = readFileSync(file, 'utf-8')
|
|
69
|
+
for (const m of text.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)/g)) {
|
|
70
|
+
usages.push({ file, token: m[1] })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('CSS custom properties', () => {
|
|
75
|
+
it('scans a meaningful number of var() references', () => {
|
|
76
|
+
expect(usages.length).toBeGreaterThan(500)
|
|
77
|
+
expect(canonicalTokens.size).toBeGreaterThan(70)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('only references custom properties that are actually defined', () => {
|
|
81
|
+
const undefinedUsages = usages.filter(({ token }) => {
|
|
82
|
+
if (canonicalTokens.has(token)) return false
|
|
83
|
+
if (RUNTIME_PROPERTY_ALLOWLIST.has(token)) return false
|
|
84
|
+
return !componentLocalProperties.has(token)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const report = [
|
|
88
|
+
...new Set(
|
|
89
|
+
undefinedUsages.map(
|
|
90
|
+
({ file, token }) => `${token} (${relative(srcRoot, file)})`,
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
].sort()
|
|
94
|
+
|
|
95
|
+
expect(report).toEqual([])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('does not use the platform-only --color-bg-*/--color-text-*/--color-border aliases', () => {
|
|
99
|
+
const platformOnly = /^--color-(bg|text)-|^--color-border$/
|
|
100
|
+
const offenders = [
|
|
101
|
+
...new Set(
|
|
102
|
+
usages
|
|
103
|
+
.filter(({ token }) => platformOnly.test(token))
|
|
104
|
+
.map(({ file, token }) => `${token} (${relative(srcRoot, file)})`),
|
|
105
|
+
),
|
|
106
|
+
].sort()
|
|
107
|
+
|
|
108
|
+
expect(offenders).toEqual([])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('aggregates every component stylesheet in styles/index.css', () => {
|
|
112
|
+
const index = readFileSync(join(stylesDir, 'index.css'), 'utf-8')
|
|
113
|
+
const imported = new Set(
|
|
114
|
+
[...index.matchAll(/@import\s+'\.\/components\/([^']+)'/g)].map((m) => m[1]),
|
|
115
|
+
)
|
|
116
|
+
const onDisk = readdirSync(join(stylesDir, 'components')).filter((f) =>
|
|
117
|
+
f.endsWith('.css'),
|
|
118
|
+
)
|
|
119
|
+
expect([...onDisk].filter((f) => !imported.has(f)).sort()).toEqual([])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('does not re-import aggregated stylesheets from SFC <style> blocks', () => {
|
|
123
|
+
const offenders = sourceFiles
|
|
124
|
+
.filter((f) => f.endsWith('.vue'))
|
|
125
|
+
.filter((f) => /@import\s+['"][^'"]*styles\/components\//.test(readFileSync(f, 'utf-8')))
|
|
126
|
+
.map((f) => relative(srcRoot, f))
|
|
127
|
+
.sort()
|
|
128
|
+
|
|
129
|
+
expect(offenders).toEqual([])
|
|
130
|
+
})
|
|
131
|
+
})
|