@bycrux/editor 0.11.2 → 1.0.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.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +601 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +137 -0
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2229 -243
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { NumberField, stepValue } from '../NumberField'
|
|
4
|
+
|
|
5
|
+
/** Renders with sensible defaults, returning the spies and `rerender` so a
|
|
6
|
+
* test can simulate the host re-rendering with a CHANGED value — the playback
|
|
7
|
+
* tick this component's `draft` exists to survive. */
|
|
8
|
+
function setup(props: Partial<React.ComponentProps<typeof NumberField>> = {}) {
|
|
9
|
+
const onPreview = vi.fn()
|
|
10
|
+
const onCommit = vi.fn()
|
|
11
|
+
const onStep = vi.fn()
|
|
12
|
+
const all = { name: 'Offset X', value: 10, onPreview, onCommit, onStep, step: 1, ...props }
|
|
13
|
+
const { rerender } = render(<NumberField {...all} />)
|
|
14
|
+
return {
|
|
15
|
+
onPreview,
|
|
16
|
+
onCommit,
|
|
17
|
+
onStep,
|
|
18
|
+
input: () => screen.getByLabelText(all.name) as HTMLInputElement,
|
|
19
|
+
rerender: (next: Partial<React.ComponentProps<typeof NumberField>>) =>
|
|
20
|
+
rerender(<NumberField {...all} {...next} />),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('NumberField — typing gesture', () => {
|
|
25
|
+
it('shows the controlled value under its accessible name', () => {
|
|
26
|
+
const { input } = setup()
|
|
27
|
+
expect(input()).toHaveValue(10)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('previews the parsed number on every keystroke, without committing', () => {
|
|
31
|
+
const { input, onPreview, onCommit } = setup()
|
|
32
|
+
|
|
33
|
+
fireEvent.change(input(), { target: { value: '4' } })
|
|
34
|
+
fireEvent.change(input(), { target: { value: '42' } })
|
|
35
|
+
|
|
36
|
+
expect(onPreview.mock.calls).toEqual([[4], [42]])
|
|
37
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('commits ONCE on blur, carrying the last previewed value', () => {
|
|
41
|
+
const { input, onCommit } = setup()
|
|
42
|
+
|
|
43
|
+
fireEvent.change(input(), { target: { value: '4' } })
|
|
44
|
+
fireEvent.change(input(), { target: { value: '42' } })
|
|
45
|
+
fireEvent.blur(input())
|
|
46
|
+
|
|
47
|
+
expect(onCommit).toHaveBeenCalledTimes(1)
|
|
48
|
+
expect(onCommit).toHaveBeenCalledWith(42)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('commits on Enter, through that same blur path', () => {
|
|
52
|
+
const { input, onCommit } = setup()
|
|
53
|
+
|
|
54
|
+
input().focus()
|
|
55
|
+
fireEvent.change(input(), { target: { value: '42' } })
|
|
56
|
+
fireEvent.keyDown(input(), { key: 'Enter' })
|
|
57
|
+
|
|
58
|
+
expect(onCommit).toHaveBeenCalledTimes(1)
|
|
59
|
+
expect(document.activeElement).not.toBe(input())
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('does not double-fire when Enter is followed by a blur', () => {
|
|
63
|
+
const { input, onCommit } = setup()
|
|
64
|
+
|
|
65
|
+
input().focus()
|
|
66
|
+
fireEvent.change(input(), { target: { value: '42' } })
|
|
67
|
+
fireEvent.keyDown(input(), { key: 'Enter' })
|
|
68
|
+
fireEvent.blur(input())
|
|
69
|
+
|
|
70
|
+
expect(onCommit).toHaveBeenCalledTimes(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('commits NOTHING when the field was focused but never edited', () => {
|
|
74
|
+
// A pointless save and an undo entry for a no-op.
|
|
75
|
+
const { input, onPreview, onCommit } = setup()
|
|
76
|
+
|
|
77
|
+
fireEvent.focus(input())
|
|
78
|
+
fireEvent.blur(input())
|
|
79
|
+
|
|
80
|
+
expect(onPreview).not.toHaveBeenCalled()
|
|
81
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('commits NOTHING when the only keystrokes were unparseable', () => {
|
|
85
|
+
const { input, onCommit } = setup()
|
|
86
|
+
|
|
87
|
+
fireEvent.change(input(), { target: { value: '' } })
|
|
88
|
+
fireEvent.blur(input())
|
|
89
|
+
|
|
90
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('re-arms for the NEXT gesture after committing', () => {
|
|
94
|
+
const { input, onCommit } = setup()
|
|
95
|
+
|
|
96
|
+
fireEvent.change(input(), { target: { value: '42' } })
|
|
97
|
+
fireEvent.blur(input())
|
|
98
|
+
|
|
99
|
+
fireEvent.change(input(), { target: { value: '43' } })
|
|
100
|
+
fireEvent.blur(input())
|
|
101
|
+
|
|
102
|
+
expect(onCommit).toHaveBeenCalledTimes(2)
|
|
103
|
+
expect(onCommit).toHaveBeenLastCalledWith(43)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('emits nothing for the mid-typing states an operator passes through', () => {
|
|
107
|
+
const { input, onPreview } = setup()
|
|
108
|
+
|
|
109
|
+
fireEvent.change(input(), { target: { value: '' } }) // cleared the box
|
|
110
|
+
fireEvent.change(input(), { target: { value: '-' } }) // typing "-5"
|
|
111
|
+
|
|
112
|
+
expect(onPreview).not.toHaveBeenCalled()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('holds the emptied box open rather than snapping back to the live value', () => {
|
|
116
|
+
// Nothing reaches the caller (previous test), but a box that refilled
|
|
117
|
+
// itself the instant it was cleared would be unusable — the draft has to
|
|
118
|
+
// stay authoritative through an unparseable state too, not just a valid
|
|
119
|
+
// one, and through a value change arriving during it.
|
|
120
|
+
const { input, rerender } = setup()
|
|
121
|
+
|
|
122
|
+
fireEvent.change(input(), { target: { value: '' } })
|
|
123
|
+
expect(input().value).toBe('')
|
|
124
|
+
|
|
125
|
+
rerender({ value: 77 })
|
|
126
|
+
expect(input().value).toBe('')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('NumberField — a mid-typed value survives a value change (the reason `draft` exists)', () => {
|
|
131
|
+
it('does not clobber the typed text when the host re-renders with a new value', () => {
|
|
132
|
+
const { input, rerender } = setup()
|
|
133
|
+
|
|
134
|
+
fireEvent.change(input(), { target: { value: '4' } }) // mid-typing "4" of "42"
|
|
135
|
+
expect(input()).toHaveValue(4)
|
|
136
|
+
|
|
137
|
+
// A playback tick arrives while the field is still mid-edit. Before
|
|
138
|
+
// `draft`, this forced the controlled input back to the incoming prop and
|
|
139
|
+
// ate the keystroke.
|
|
140
|
+
rerender({ value: 77 })
|
|
141
|
+
expect(input()).toHaveValue(4)
|
|
142
|
+
|
|
143
|
+
fireEvent.change(input(), { target: { value: '42' } })
|
|
144
|
+
expect(input()).toHaveValue(42)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('goes back to tracking the live value once the gesture commits', () => {
|
|
148
|
+
const { input, rerender } = setup()
|
|
149
|
+
|
|
150
|
+
fireEvent.change(input(), { target: { value: '4' } })
|
|
151
|
+
rerender({ value: 77 })
|
|
152
|
+
fireEvent.blur(input())
|
|
153
|
+
|
|
154
|
+
// Proves blur RELEASED the draft rather than freezing the typed text.
|
|
155
|
+
expect(input()).toHaveValue(77)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('tracks the live value while untouched — an animating prop still animates', () => {
|
|
159
|
+
const { input, rerender } = setup()
|
|
160
|
+
|
|
161
|
+
rerender({ value: 55 })
|
|
162
|
+
|
|
163
|
+
expect(input()).toHaveValue(55)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('NumberField — steppers', () => {
|
|
168
|
+
it('nudges in each direction as a discrete edit, never a preview/commit pair', () => {
|
|
169
|
+
const { onStep, onPreview, onCommit } = setup({ name: 'Opacity' })
|
|
170
|
+
|
|
171
|
+
fireEvent.click(screen.getByRole('button', { name: 'Increase Opacity' }))
|
|
172
|
+
fireEvent.click(screen.getByRole('button', { name: 'Decrease Opacity' }))
|
|
173
|
+
|
|
174
|
+
expect(onStep.mock.calls).toEqual([[1], [-1]])
|
|
175
|
+
expect(onPreview).not.toHaveBeenCalled()
|
|
176
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('renders no steppers at all when the caller has no discrete nudge for them', () => {
|
|
180
|
+
setup({ onStep: undefined })
|
|
181
|
+
|
|
182
|
+
expect(screen.queryByRole('button')).toBeNull()
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
describe('NumberField — decoration', () => {
|
|
187
|
+
it('renders the prefix and unit, hidden from assistive tech', () => {
|
|
188
|
+
setup({ prefix: 'X', unit: '%' })
|
|
189
|
+
|
|
190
|
+
// The accessible name still reads once, from the box's own aria-label.
|
|
191
|
+
expect(screen.getByLabelText('Offset X')).toBeInTheDocument()
|
|
192
|
+
expect(screen.getByText('X')).toHaveAttribute('aria-hidden', 'true')
|
|
193
|
+
expect(screen.getByText('%')).toHaveAttribute('aria-hidden', 'true')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('shows an empty box for a value the caller has no explicit setting for', () => {
|
|
197
|
+
// `''` is not zero — it means "nothing stored", and the placeholder is
|
|
198
|
+
// what makes the field honest about what is actually in force.
|
|
199
|
+
const { input } = setup({ value: '', placeholder: '1.3' })
|
|
200
|
+
|
|
201
|
+
expect(input().value).toBe('')
|
|
202
|
+
expect(input()).toHaveAttribute('placeholder', '1.3')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('renders an EMPTY placeholder as a real attribute, not an absent one', () => {
|
|
206
|
+
// A caller whose active default has no value to report still needs the
|
|
207
|
+
// attribute there — dropping it would fall back to some other placeholder.
|
|
208
|
+
const { input } = setup({ value: '', placeholder: '' })
|
|
209
|
+
|
|
210
|
+
expect(input()).toHaveAttribute('placeholder', '')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('shows a stored value rather than the placeholder when it has one', () => {
|
|
214
|
+
const { input } = setup({ value: '0.08', placeholder: '-0.02' })
|
|
215
|
+
|
|
216
|
+
expect(input().value).toBe('0.08')
|
|
217
|
+
expect(input()).toHaveAttribute('placeholder', '-0.02')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('snaps an emptied box back to the live value on blur', () => {
|
|
221
|
+
const { input } = setup({ value: 10 })
|
|
222
|
+
|
|
223
|
+
fireEvent.change(input(), { target: { value: '' } })
|
|
224
|
+
expect(input().value).toBe('')
|
|
225
|
+
|
|
226
|
+
fireEvent.blur(input())
|
|
227
|
+
|
|
228
|
+
expect(input()).toHaveValue(10)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('passes step/min/max to the box for native validation', () => {
|
|
232
|
+
const { input } = setup({ step: 0.01, min: 0, max: 1, value: 0.5 })
|
|
233
|
+
|
|
234
|
+
expect(input()).toHaveAttribute('step', '0.01')
|
|
235
|
+
expect(input()).toHaveAttribute('min', '0')
|
|
236
|
+
expect(input()).toHaveAttribute('max', '1')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('does NOT clamp a typed value — a bound only limits the stepper', () => {
|
|
240
|
+
// Both assertions deliberately use values OUTSIDE the bounds. A value on
|
|
241
|
+
// the boundary would prove nothing: `Math.max(min, 5)` is also 5, so a
|
|
242
|
+
// clamping implementation would pass such a test unchanged.
|
|
243
|
+
const { input, onPreview } = setup({ min: 5, max: 400, value: 100 })
|
|
244
|
+
|
|
245
|
+
// Past the ceiling: `500` typed on the way to a bigger number must arrive
|
|
246
|
+
// whole, not rewritten to 400 between keystrokes.
|
|
247
|
+
fireEvent.change(input(), { target: { value: '500' } })
|
|
248
|
+
expect(onPreview).toHaveBeenLastCalledWith(500)
|
|
249
|
+
|
|
250
|
+
// And under the floor: `1` typed on the way to `12` must not become 5.
|
|
251
|
+
fireEvent.change(input(), { target: { value: '1' } })
|
|
252
|
+
expect(onPreview).toHaveBeenLastCalledWith(1)
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
describe('NumberField — disabled', () => {
|
|
257
|
+
it('disables the box itself', () => {
|
|
258
|
+
const { input } = setup({ disabled: true })
|
|
259
|
+
expect(input()).toBeDisabled()
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('disables the steppers too — a live arrow beside a dead box would be a bug', () => {
|
|
263
|
+
setup({ name: 'Opacity', disabled: true })
|
|
264
|
+
|
|
265
|
+
expect(screen.getByRole('button', { name: 'Increase Opacity' })).toBeDisabled()
|
|
266
|
+
expect(screen.getByRole('button', { name: 'Decrease Opacity' })).toBeDisabled()
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('a disabled stepper click does not fire onStep', () => {
|
|
270
|
+
const { onStep } = setup({ name: 'Opacity', disabled: true })
|
|
271
|
+
|
|
272
|
+
fireEvent.click(screen.getByRole('button', { name: 'Increase Opacity' }))
|
|
273
|
+
|
|
274
|
+
expect(onStep).not.toHaveBeenCalled()
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
describe('stepValue', () => {
|
|
279
|
+
it('increments and decrements by one step', () => {
|
|
280
|
+
expect(stepValue(10, 1, { step: 1 })).toBe(11)
|
|
281
|
+
expect(stepValue(10, -1, { step: 1 })).toBe(9)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('rounds away the float noise the nudge itself introduces', () => {
|
|
285
|
+
// 1.2 + 0.01 is 1.2100000000000002 in IEEE 754.
|
|
286
|
+
expect(stepValue(1.2, 1, { step: 0.01 })).toBe(1.21)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('clamps at min and max', () => {
|
|
290
|
+
expect(stepValue(0.01, -1, { step: 0.01, min: 0.01 })).toBe(0.01)
|
|
291
|
+
expect(stepValue(1, 1, { step: 0.01, min: 0, max: 1 })).toBe(1)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('leaves an unbounded value unbounded', () => {
|
|
295
|
+
expect(stepValue(-5, -1, { step: 1 })).toBe(-6)
|
|
296
|
+
expect(stepValue(999, 1, { step: 1 })).toBe(1000)
|
|
297
|
+
})
|
|
298
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { Slider } from '../Slider'
|
|
4
|
+
|
|
5
|
+
function setup(props: Partial<React.ComponentProps<typeof Slider>> = {}) {
|
|
6
|
+
const onChange = vi.fn()
|
|
7
|
+
const onCommit = vi.fn()
|
|
8
|
+
const all = {
|
|
9
|
+
'aria-label': 'Scale slider' as const,
|
|
10
|
+
value: 1,
|
|
11
|
+
min: 0,
|
|
12
|
+
max: 4,
|
|
13
|
+
step: 0.01,
|
|
14
|
+
onChange,
|
|
15
|
+
onCommit,
|
|
16
|
+
...props,
|
|
17
|
+
}
|
|
18
|
+
render(<Slider {...all} />)
|
|
19
|
+
return {
|
|
20
|
+
onChange,
|
|
21
|
+
onCommit,
|
|
22
|
+
slider: () => screen.getByRole('slider', { name: all['aria-label'] }) as HTMLInputElement,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('Slider — value + semantics', () => {
|
|
27
|
+
it('exposes the range natively, under its accessible name', () => {
|
|
28
|
+
const { slider } = setup()
|
|
29
|
+
|
|
30
|
+
expect(slider().value).toBe('1')
|
|
31
|
+
expect(slider()).toHaveAttribute('type', 'range')
|
|
32
|
+
expect(slider()).toHaveAttribute('min', '0')
|
|
33
|
+
expect(slider()).toHaveAttribute('max', '4')
|
|
34
|
+
expect(slider()).toHaveAttribute('step', '0.01')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('previews the parsed number on every change', () => {
|
|
38
|
+
const { slider, onChange } = setup()
|
|
39
|
+
|
|
40
|
+
fireEvent.change(slider(), { target: { value: '1.5' } })
|
|
41
|
+
fireEvent.change(slider(), { target: { value: '1.6' } })
|
|
42
|
+
|
|
43
|
+
expect(onChange.mock.calls).toEqual([[1.5], [1.6]])
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('Slider — one commit per gesture', () => {
|
|
48
|
+
it('commits ONCE on pointer up, not per change, and not again on the trailing blur', () => {
|
|
49
|
+
const { slider, onCommit } = setup()
|
|
50
|
+
|
|
51
|
+
fireEvent.change(slider(), { target: { value: '1.5' } })
|
|
52
|
+
fireEvent.change(slider(), { target: { value: '1.6' } })
|
|
53
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
54
|
+
|
|
55
|
+
fireEvent.pointerUp(slider())
|
|
56
|
+
expect(onCommit).toHaveBeenCalledTimes(1)
|
|
57
|
+
|
|
58
|
+
// A pointerup is normally followed by a blur — that must not stack a
|
|
59
|
+
// second undo entry onto the same drag.
|
|
60
|
+
fireEvent.blur(slider())
|
|
61
|
+
expect(onCommit).toHaveBeenCalledTimes(1)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('reports the last previewed value to the commit', () => {
|
|
65
|
+
const { slider, onCommit } = setup()
|
|
66
|
+
|
|
67
|
+
fireEvent.change(slider(), { target: { value: '1.5' } })
|
|
68
|
+
fireEvent.change(slider(), { target: { value: '2.25' } })
|
|
69
|
+
fireEvent.pointerUp(slider())
|
|
70
|
+
|
|
71
|
+
expect(onCommit).toHaveBeenCalledWith(2.25)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('commits a keyboard adjustment on key up', () => {
|
|
75
|
+
const { slider, onCommit } = setup()
|
|
76
|
+
|
|
77
|
+
fireEvent.change(slider(), { target: { value: '1.3' } })
|
|
78
|
+
fireEvent.keyUp(slider(), { key: 'ArrowRight' })
|
|
79
|
+
|
|
80
|
+
expect(onCommit).toHaveBeenCalledTimes(1)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('commits NOTHING for a gesture that never changed anything', () => {
|
|
84
|
+
// A click landing on the thumb's current position, or a focus/blur that
|
|
85
|
+
// touched nothing, must not spend an undo entry on a no-op.
|
|
86
|
+
const { slider, onCommit } = setup()
|
|
87
|
+
|
|
88
|
+
fireEvent.pointerUp(slider())
|
|
89
|
+
fireEvent.keyUp(slider(), { key: 'ArrowRight' })
|
|
90
|
+
fireEvent.blur(slider())
|
|
91
|
+
|
|
92
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('re-arms for the NEXT gesture after committing', () => {
|
|
96
|
+
const { slider, onCommit } = setup()
|
|
97
|
+
|
|
98
|
+
fireEvent.change(slider(), { target: { value: '1.5' } })
|
|
99
|
+
fireEvent.pointerUp(slider())
|
|
100
|
+
|
|
101
|
+
fireEvent.change(slider(), { target: { value: '2' } })
|
|
102
|
+
fireEvent.pointerUp(slider())
|
|
103
|
+
|
|
104
|
+
expect(onCommit).toHaveBeenCalledTimes(2)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('is usable with no onCommit at all — a slider whose changes are already final', () => {
|
|
108
|
+
const { slider, onChange } = setup({ onCommit: undefined })
|
|
109
|
+
|
|
110
|
+
fireEvent.change(slider(), { target: { value: '2' } })
|
|
111
|
+
expect(() => fireEvent.pointerUp(slider())).not.toThrow()
|
|
112
|
+
expect(onChange).toHaveBeenCalledWith(2)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('Slider — keyboard', () => {
|
|
117
|
+
it('is focusable, so the native range keyboard bindings apply', () => {
|
|
118
|
+
// The look is replaced, the control is not: it stays a native
|
|
119
|
+
// `<input type="range">` precisely so arrows/Home/End keep working.
|
|
120
|
+
const { slider } = setup()
|
|
121
|
+
|
|
122
|
+
slider().focus()
|
|
123
|
+
|
|
124
|
+
expect(document.activeElement).toBe(slider())
|
|
125
|
+
expect(slider()).not.toHaveAttribute('tabindex', '-1')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('honours `disabled`', () => {
|
|
129
|
+
const { slider } = setup({ disabled: true })
|
|
130
|
+
|
|
131
|
+
expect(slider()).toBeDisabled()
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('Slider — theming', () => {
|
|
136
|
+
it('draws track and thumb from theme vars, never a literal color', () => {
|
|
137
|
+
// The control has to read correctly on both the light and dark editor
|
|
138
|
+
// grounds; a hardcoded fill would only work on one of them. This is
|
|
139
|
+
// deliberately the ONLY assertion here — checking for the presence of
|
|
140
|
+
// specific `bg-[var(--editor-*)]` classes would just be `SLIDER_CLASS`
|
|
141
|
+
// copied into the test: it could only fail on a rename (which teaches
|
|
142
|
+
// nothing about rendering) and would false-fail the first legitimate
|
|
143
|
+
// token rename. The real rule this control must never break is "no
|
|
144
|
+
// literal color", which is what stays under test.
|
|
145
|
+
const { slider } = setup()
|
|
146
|
+
const cls = slider().className
|
|
147
|
+
|
|
148
|
+
expect(cls).not.toMatch(/#[0-9a-f]{3,8}\b/i)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* The styled hover pill that replaced native `title=` on the editor's tool
|
|
4
|
+
* buttons. What matters is that it appears on hover with no delay, leaves on
|
|
5
|
+
* every exit path, and renders outside the trigger's clipping ancestors.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
8
|
+
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
|
9
|
+
import { Tooltip } from '../Tooltip'
|
|
10
|
+
|
|
11
|
+
afterEach(() => cleanup())
|
|
12
|
+
|
|
13
|
+
function renderTooltip(props: Partial<React.ComponentProps<typeof Tooltip>> = {}) {
|
|
14
|
+
return render(
|
|
15
|
+
<Tooltip label="Undo" {...props}>
|
|
16
|
+
<button aria-label="Undo">u</button>
|
|
17
|
+
</Tooltip>,
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('Tooltip', () => {
|
|
22
|
+
it('is absent until hover, then shows the label immediately', () => {
|
|
23
|
+
renderTooltip()
|
|
24
|
+
expect(screen.queryByRole('tooltip')).toBeNull()
|
|
25
|
+
|
|
26
|
+
fireEvent.mouseEnter(screen.getByLabelText('Undo').parentElement!)
|
|
27
|
+
// No timers advanced: the pill is up on the first event, unlike `title=`.
|
|
28
|
+
expect(screen.getByRole('tooltip').textContent).toContain('Undo')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('renders shortcut chips as <kbd> after the label', () => {
|
|
32
|
+
renderTooltip({ keys: ['⌘', 'Z'] })
|
|
33
|
+
fireEvent.mouseEnter(screen.getByLabelText('Undo').parentElement!)
|
|
34
|
+
|
|
35
|
+
const chips = screen.getAllByText(/^[⌘Z]$/)
|
|
36
|
+
expect(chips.map((c) => c.tagName)).toEqual(['KBD', 'KBD'])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('hides on mouse leave', () => {
|
|
40
|
+
renderTooltip()
|
|
41
|
+
const wrapper = screen.getByLabelText('Undo').parentElement!
|
|
42
|
+
fireEvent.mouseEnter(wrapper)
|
|
43
|
+
expect(screen.getByRole('tooltip')).toBeTruthy()
|
|
44
|
+
|
|
45
|
+
fireEvent.mouseLeave(wrapper)
|
|
46
|
+
expect(screen.queryByRole('tooltip')).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('hides on press, so a button that opens a modal leaves no orphan pill', () => {
|
|
50
|
+
renderTooltip()
|
|
51
|
+
const wrapper = screen.getByLabelText('Undo').parentElement!
|
|
52
|
+
fireEvent.mouseEnter(wrapper)
|
|
53
|
+
fireEvent.mouseDown(wrapper)
|
|
54
|
+
expect(screen.queryByRole('tooltip')).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('shows on keyboard focus and hides on blur', () => {
|
|
58
|
+
renderTooltip()
|
|
59
|
+
const button = screen.getByLabelText('Undo')
|
|
60
|
+
fireEvent.focus(button)
|
|
61
|
+
expect(screen.getByRole('tooltip')).toBeTruthy()
|
|
62
|
+
|
|
63
|
+
fireEvent.blur(button)
|
|
64
|
+
expect(screen.queryByRole('tooltip')).toBeNull()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('portals the pill to document.body, escaping the trigger\'s overflow ancestors', () => {
|
|
68
|
+
const { container } = render(
|
|
69
|
+
<div style={{ overflow: 'hidden' }}>
|
|
70
|
+
<Tooltip label="Fit to view">
|
|
71
|
+
<button aria-label="Fit to view">fit</button>
|
|
72
|
+
</Tooltip>
|
|
73
|
+
</div>,
|
|
74
|
+
)
|
|
75
|
+
fireEvent.mouseEnter(screen.getByLabelText('Fit to view').parentElement!)
|
|
76
|
+
|
|
77
|
+
const pill = screen.getByRole('tooltip')
|
|
78
|
+
expect(container.contains(pill)).toBe(false)
|
|
79
|
+
expect(document.body.contains(pill)).toBe(true)
|
|
80
|
+
// Fixed positioning is what makes the escape work — an absolute pill would
|
|
81
|
+
// still resolve against a positioned ancestor.
|
|
82
|
+
expect(pill.className).toContain('fixed')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('is inert to the pointer, so it can never eat the click it describes', () => {
|
|
86
|
+
const onClick = vi.fn()
|
|
87
|
+
render(
|
|
88
|
+
<Tooltip label="Split at playhead">
|
|
89
|
+
<button aria-label="Split" onClick={onClick}>s</button>
|
|
90
|
+
</Tooltip>,
|
|
91
|
+
)
|
|
92
|
+
const button = screen.getByLabelText('Split')
|
|
93
|
+
fireEvent.mouseEnter(button.parentElement!)
|
|
94
|
+
expect(screen.getByRole('tooltip').className).toContain('pointer-events-none')
|
|
95
|
+
|
|
96
|
+
fireEvent.click(button)
|
|
97
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('puts layout classes on the wrapper, which is the flex item', () => {
|
|
101
|
+
renderTooltip({ className: 'mr-auto' })
|
|
102
|
+
const wrapper = screen.getByLabelText('Undo').parentElement!
|
|
103
|
+
expect(wrapper.className).toBe('inline-flex mr-auto')
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* The layout preferences that survive a reload (timeline pane height, caption
|
|
4
|
+
* row visibility). What matters is that a bad or hostile stored value can never
|
|
5
|
+
* take the editor down — a preference is not worth a crash the user can only
|
|
6
|
+
* escape by clearing site data.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
9
|
+
import { render, screen, fireEvent, cleanup, act } from '@testing-library/react'
|
|
10
|
+
import { reviveBoolean, reviveNumberInRange, usePersistentState } from '../usePersistentState'
|
|
11
|
+
|
|
12
|
+
const KEY = 'test.pref'
|
|
13
|
+
|
|
14
|
+
function Probe({ revive = reviveNumberInRange(10, 100), initial = 50 } = {}) {
|
|
15
|
+
const [value, set] = usePersistentState(KEY, initial, revive)
|
|
16
|
+
return (
|
|
17
|
+
<div>
|
|
18
|
+
<span data-testid="value">{String(value)}</span>
|
|
19
|
+
<button onClick={() => set(77 as never)}>persist</button>
|
|
20
|
+
<button onClick={() => set(88 as never, { persist: false })}>transient</button>
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeEach(() => window.localStorage.clear())
|
|
26
|
+
afterEach(() => cleanup())
|
|
27
|
+
|
|
28
|
+
describe('usePersistentState', () => {
|
|
29
|
+
it('starts at the initial value when nothing is stored', () => {
|
|
30
|
+
render(<Probe />)
|
|
31
|
+
expect(screen.getByTestId('value').textContent).toBe('50')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('restores a previously stored value', () => {
|
|
35
|
+
window.localStorage.setItem(KEY, '42')
|
|
36
|
+
render(<Probe />)
|
|
37
|
+
expect(screen.getByTestId('value').textContent).toBe('42')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('writes through on a normal set', () => {
|
|
41
|
+
render(<Probe />)
|
|
42
|
+
fireEvent.click(screen.getByText('persist'))
|
|
43
|
+
expect(screen.getByTestId('value').textContent).toBe('77')
|
|
44
|
+
expect(window.localStorage.getItem(KEY)).toBe('77')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('applies a transient set without writing it — the drag-frame case', () => {
|
|
48
|
+
// Every mousemove of a divider drag calls this; persisting each one would
|
|
49
|
+
// mean ~60 localStorage writes a second for a value only the last of which
|
|
50
|
+
// matters.
|
|
51
|
+
render(<Probe />)
|
|
52
|
+
fireEvent.click(screen.getByText('transient'))
|
|
53
|
+
expect(screen.getByTestId('value').textContent).toBe('88')
|
|
54
|
+
expect(window.localStorage.getItem(KEY)).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('rejects a stored value outside the allowed range', () => {
|
|
58
|
+
// A height saved on a much taller window must not open the editor with the
|
|
59
|
+
// preview pushed off-screen.
|
|
60
|
+
window.localStorage.setItem(KEY, '99999')
|
|
61
|
+
render(<Probe />)
|
|
62
|
+
expect(screen.getByTestId('value').textContent).toBe('50')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('falls back to the initial value on unparseable JSON', () => {
|
|
66
|
+
window.localStorage.setItem(KEY, 'not json{')
|
|
67
|
+
render(<Probe />)
|
|
68
|
+
expect(screen.getByTestId('value').textContent).toBe('50')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('falls back on a value of the wrong type', () => {
|
|
72
|
+
window.localStorage.setItem(KEY, '"tall"')
|
|
73
|
+
render(<Probe />)
|
|
74
|
+
expect(screen.getByTestId('value').textContent).toBe('50')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('survives localStorage throwing on read — Safari private mode', () => {
|
|
78
|
+
const spy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('denied') })
|
|
79
|
+
render(<Probe />)
|
|
80
|
+
expect(screen.getByTestId('value').textContent).toBe('50')
|
|
81
|
+
spy.mockRestore()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('survives localStorage throwing on write, keeping the value in memory', () => {
|
|
85
|
+
const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('quota') })
|
|
86
|
+
render(<Probe />)
|
|
87
|
+
act(() => { fireEvent.click(screen.getByText('persist')) })
|
|
88
|
+
expect(screen.getByTestId('value').textContent).toBe('77')
|
|
89
|
+
spy.mockRestore()
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('revivers', () => {
|
|
94
|
+
it('reviveNumberInRange accepts the bounds and rejects everything unusable', () => {
|
|
95
|
+
const revive = reviveNumberInRange(10, 100)
|
|
96
|
+
expect(revive(10)).toBe(10)
|
|
97
|
+
expect(revive(100)).toBe(100)
|
|
98
|
+
expect(revive(9)).toBeNull()
|
|
99
|
+
expect(revive(101)).toBeNull()
|
|
100
|
+
expect(revive(Number.NaN)).toBeNull()
|
|
101
|
+
expect(revive(Number.POSITIVE_INFINITY)).toBeNull()
|
|
102
|
+
expect(revive('50')).toBeNull()
|
|
103
|
+
expect(revive(null)).toBeNull()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('reviveBoolean takes only real booleans', () => {
|
|
107
|
+
expect(reviveBoolean(true)).toBe(true)
|
|
108
|
+
expect(reviveBoolean(false)).toBe(false)
|
|
109
|
+
expect(reviveBoolean('true')).toBeNull()
|
|
110
|
+
expect(reviveBoolean(1)).toBeNull()
|
|
111
|
+
})
|
|
112
|
+
})
|
package/src/ui/badge.tsx
CHANGED
|
@@ -6,10 +6,18 @@ const badgeVariants = cva(
|
|
|
6
6
|
{
|
|
7
7
|
variants: {
|
|
8
8
|
variant: {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
// Status hues are deliberately a single fixed appearance rather than
|
|
10
|
+
// a `dark:`-split one: `dark:` here would follow the HOST page's
|
|
11
|
+
// `<html class="dark">`, not the `theme` prop passed to this editor
|
|
12
|
+
// instance — a host can pass `lightMontajTheme` while its own chrome
|
|
13
|
+
// stays dark (or vice versa), so the two can disagree. A soft pastel
|
|
14
|
+
// chip is legible on any editor ground (verified ~4.6:1+ on a light
|
|
15
|
+
// surface, the new case this sweep adds; it simply pops as a bright
|
|
16
|
+
// chip on a dark one, which reads fine for a status indicator).
|
|
17
|
+
pending: 'bg-amber-100 text-amber-700',
|
|
18
|
+
draft: 'bg-blue-100 text-blue-700',
|
|
19
|
+
final: 'bg-emerald-100 text-emerald-700',
|
|
20
|
+
default: 'bg-[var(--editor-surface)] text-[var(--editor-text)]',
|
|
13
21
|
},
|
|
14
22
|
},
|
|
15
23
|
defaultVariants: { variant: 'default' },
|