@getdom/studio 0.1.1 → 0.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/CHANGELOG.md +54 -0
  2. package/README.md +31 -0
  3. package/SKILL.md +21 -1
  4. package/package.json +10 -4
  5. package/public/llms.txt +13 -2
  6. package/src/pages/_layout/componentCatalog.generated.js +1185 -44
  7. package/src/pages/_layout/componentLoaders.js +41 -0
  8. package/src/pages/_layout/componentManager.js +38 -27
  9. package/src/pages/_layout/componentRuntimeRecords.js +3 -1
  10. package/src/pages/charts/_shared/ChartContainer.vue +67 -0
  11. package/src/pages/charts/_shared/ChartLegend.vue +22 -0
  12. package/src/pages/charts/_shared/ChartTooltip.vue +18 -0
  13. package/src/pages/charts/_shared/DonutRenderer.vue +33 -0
  14. package/src/pages/charts/_shared/XYChart.vue +140 -0
  15. package/src/pages/charts/_shared/XYRenderer.vue +90 -0
  16. package/src/pages/charts/_shared/chartProps.js +23 -0
  17. package/src/pages/charts/_shared/chartUtils.js +95 -0
  18. package/src/pages/charts/area-chart/DomAreaChart.vue +27 -0
  19. package/src/pages/charts/bar-chart/DomBarChart.vue +29 -0
  20. package/src/pages/charts/date-range-picker/DomDateRangePicker.vue +104 -0
  21. package/src/pages/charts/date-range-picker/dateRange.js +69 -0
  22. package/src/pages/charts/donut-chart/DomDonutChart.vue +117 -0
  23. package/src/pages/charts/line-chart/DomLineChart.vue +26 -0
  24. package/src/pages/charts/metric-card/DomMetricCard.vue +49 -0
  25. package/src/pages/charts/sparkline/DomSparkline.vue +30 -0
  26. package/src/pages/components/_shared/media/MediaDeviceSelect.vue +28 -0
  27. package/src/pages/components/_shared/media/useMediaInput.js +200 -0
  28. package/src/pages/components/audio-controls/DomAudioControls.vue +91 -0
  29. package/src/pages/components/audio-input/DomAudioInput.vue +58 -0
  30. package/src/pages/components/button/DomButton.vue +10 -4
  31. package/src/pages/components/button/buttonStyles.js +5 -0
  32. package/src/pages/components/camera-input/DomCameraInput.vue +75 -0
  33. package/src/pages/components/command-palette/DomCommandPalette.vue +12 -2
  34. package/src/pages/components/dialog/DomDialog.vue +338 -36
  35. package/src/pages/components/icon-button/DomIconButton.vue +8 -2
  36. package/src/pages/components/menu-stack/DomMenuStack.vue +111 -0
  37. package/src/pages/components/menu-stack/menuTree.js +35 -0
  38. package/src/pages/components/popover/DomPopover.vue +1 -53
  39. package/src/pages/components/popover/popover.css +50 -0
  40. package/src/pages/components/video-feed/DomVideoFeed.vue +551 -0
  41. package/src/pages/data/agent-chat/DomAgentChatShell.vue +1 -0
  42. package/src/pages/data/agent-chat/DomAgentConversation.vue +1 -0
  43. package/src/pages/data/agent-chat/DomAgentConversationList.vue +1 -0
  44. package/src/pages/data/gantt/DomGantt.vue +788 -0
  45. package/src/pages/data/gantt/ganttTime.js +223 -0
  46. package/src/pages/data/month-calendar/DomMonthCalendar.vue +84 -9
  47. package/src/pages/data/notification-inbox/DomNotificationInbox.vue +505 -0
  48. package/src/pages/data/notification-inbox/notificationPresentation.js +34 -0
  49. package/src/pages/designer/_shared/ControlIconButton.vue +62 -0
  50. package/src/pages/designer/_shared/StyleControlSection.vue +59 -0
  51. package/src/pages/designer/_shared/cssEditorContext.js +19 -0
  52. package/src/pages/designer/backdrop-editor/BackdropControl.vue +57 -0
  53. package/src/pages/designer/backdrop-editor/DomBackdropEditor.vue +33 -0
  54. package/src/pages/designer/backdrop-editor/backdropModel.js +39 -0
  55. package/src/pages/designer/background-editor/BackgroundLayers.vue +210 -0
  56. package/src/pages/designer/background-editor/DomBackgroundEditor.vue +33 -0
  57. package/src/pages/designer/background-editor/backgroundModel.js +102 -0
  58. package/src/pages/designer/border-editor/BorderControl.vue +72 -0
  59. package/src/pages/designer/border-editor/DomBorderEditor.vue +34 -0
  60. package/src/pages/designer/border-editor/borderModel.js +21 -0
  61. package/src/pages/designer/color-swatch/DomColorSwatch.vue +95 -0
  62. package/src/pages/designer/frame/DomDesignerFrame.vue +16 -0
  63. package/src/pages/designer/frame/DomFrameLayoutEditor.vue +41 -0
  64. package/src/pages/designer/frame/frameLayout.js +93 -0
  65. package/src/pages/designer/layers/DomDesignerLayers.vue +11 -0
  66. package/src/pages/designer/shadow-editor/DomShadowEditor.vue +32 -0
  67. package/src/pages/designer/shadow-editor/ShadowLayers.vue +185 -0
  68. package/src/pages/designer/shadow-editor/shadowModel.js +73 -0
  69. package/src/pages/designer/stage/DesignerBounds.vue +31 -0
  70. package/src/pages/designer/stage/DomDesignerStage.vue +237 -0
  71. package/src/pages/designer/stage/SpacingGuides.vue +33 -0
  72. package/src/pages/designer/stage/resizeGeometry.js +15 -0
  73. package/src/pages/designer/stage/snapGeometry.js +69 -0
  74. package/src/pages/designer/stage/snapPipeline.js +17 -0
  75. package/src/pages/designer/stage/spacingGeometry.js +129 -0
  76. package/src/pages/forms/native-select/DomNativeSelect.vue +8 -3
  77. package/src/pages/forms/number-input/DomNumberInput.vue +39 -3
  78. package/src/pages/forms/range-input/DomRangeInput.vue +75 -23
  79. package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +91 -9
  80. package/src/pages/forms/toggle/DomToggle.vue +1 -1
  81. package/src/pages/lib/headless/base.js +10 -5
  82. package/src/pages/lib/headless/dialog.js +25 -6
  83. package/src/pages/lib/headless/drawer.js +49 -21
  84. package/src/pages/lib/vue/index.d.ts +1161 -14
  85. package/src/pages/lib/vue/index.js +43 -0
  86. package/src/pages/mobile/_shared/interactionMath.js +46 -0
  87. package/src/pages/mobile/_shared/useKeyboardViewport.js +91 -0
  88. package/src/pages/mobile/action-sheet/DomActionSheet.vue +102 -65
  89. package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +4 -2
  90. package/src/pages/mobile/bottom-sheet/DomBottomSheet.vue +221 -0
  91. package/src/pages/mobile/list-item/DomAppListItem.vue +6 -6
  92. package/src/pages/mobile/profile/DomAppProfile.vue +42 -0
  93. package/src/pages/mobile/safe-area/DomSafeArea.vue +6 -6
  94. package/src/pages/mobile/shell/DomAppShell.vue +60 -9
  95. package/src/pages/mobile/stack/DomAppStack.vue +214 -55
  96. package/src/pages/mobile/swipe-actions/DomSwipeActions.vue +346 -0
  97. package/src/pages/mobile/top-bar/DomAppTopBar.vue +3 -3
  98. package/src/pages/visual/audio-visualizer/DomAudioVisualizer.vue +143 -0
  99. package/src/theme.css +13 -0
@@ -0,0 +1,237 @@
1
+ <script setup>
2
+ import DesignerBounds from './DesignerBounds.vue';
3
+ import { resizeBounds } from './resizeGeometry.js';
4
+ import { createSnapIndex, snapResize } from './snapGeometry.js';
5
+ import { createSpacingIndex } from './spacingGeometry.js';
6
+ import { snapTranslation } from './snapPipeline.js';
7
+ import SpacingGuides from './SpacingGuides.vue';
8
+ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
9
+
10
+ defineOptions({ __doc: { name: 'Designer stage', description: 'Beta HTML stage with controlled selection and opt-in absolute movement and resizing.' } });
11
+ const props = defineProps({
12
+ modelValue: { type: String, default: '' },
13
+ positioning: { type: Boolean, default: false },
14
+ interactive: { type: Boolean, default: false },
15
+ snapping: { type: Boolean, default: false },
16
+ spacingSnapping: { type: Boolean, default: false, _edit: { description: 'Repeat horizontal and vertical layer gaps while dragging. Independent of alignment snapping; disabled by default.' } },
17
+ frameScoped: { type: Boolean, default: false, _edit: { description: 'Only move children of an absolute data-designer-frame. Snap to that frame and its direct children.' } },
18
+ });
19
+ const emit = defineEmits(['update:modelValue', 'style-change']);
20
+ const root = ref(null);
21
+ const box = ref(null);
22
+ const viewScale = ref(1);
23
+ const guides = ref({});
24
+ const canPosition = ref(false);
25
+ const guideClip = ref(null);
26
+ let moveFrame;
27
+ let pendingMove;
28
+ let observer;
29
+ let mutationObserver;
30
+ let gesture;
31
+
32
+ /** Finds a marked element without interpolating application identifiers into selectors. */
33
+ function selectedElement() {
34
+ if (gesture?.element) return gesture.element;
35
+ return [...(root.value?.querySelectorAll('[data-designer-id]') || [])].find((el) => el.dataset.designerId === props.modelValue);
36
+ }
37
+ /** Resolves the nearest containing frame without changing legacy stage behaviour. */
38
+ function containingFrame(element) {
39
+ return element?.parentElement?.closest('[data-designer-frame]');
40
+ }
41
+ /** Allows free movement only where the parent, rather than the selected frame, is absolute. */
42
+ function permitsPositioning(element) {
43
+ const frame = containingFrame(element);
44
+ return props.positioning && (!props.frameScoped || (frame?.dataset.designerFrame === 'absolute' && element.parentElement === frame));
45
+ }
46
+ /** Measures the selection in the stage's CSS coordinates, including its scroll offset. */
47
+ function measure() {
48
+ const el = selectedElement();
49
+ canPosition.value = permitsPositioning(el);
50
+ if (!el || !root.value) { box.value = null; return; }
51
+ const r = el.getBoundingClientRect();
52
+ const stage = root.value.getBoundingClientRect();
53
+ const scale = stage.width / root.value.offsetWidth || 1;
54
+ viewScale.value = scale;
55
+ box.value = { left: (r.left - stage.left) / scale - root.value.clientLeft + root.value.scrollLeft, top: (r.top - stage.top) / scale - root.value.clientTop + root.value.scrollTop, width: r.width / scale, height: r.height / scale };
56
+ }
57
+ /** Selects the innermost marked HTML layer and prevents preview controls from activating. */
58
+ function select(event) {
59
+ if (props.interactive || event.target.closest('[data-designer-overlay]')) return;
60
+ const el = event.target.closest('[data-designer-id]');
61
+ if (!el || !root.value.contains(el)) return;
62
+ event.preventDefault();
63
+ event.stopPropagation();
64
+ emit('update:modelValue', el.dataset.designerId);
65
+ }
66
+ /** Begins a captured pointer gesture; conversion to absolute positioning occurs on movement. */
67
+ function begin(event, resize = '') {
68
+ if (event.button !== 0) return;
69
+ const el = selectedElement();
70
+ if (!el || !permitsPositioning(el)) return;
71
+ if (!resize) {
72
+ const hit = el.ownerDocument.elementsFromPoint(event.clientX, event.clientY).find((item) => item.hasAttribute('data-designer-id') && root.value.contains(item));
73
+ if (hit && hit.dataset.designerId !== props.modelValue) {
74
+ emit('update:modelValue', hit.dataset.designerId);
75
+ return;
76
+ }
77
+ }
78
+ event.preventDefault();
79
+ event.currentTarget.focus();
80
+ const parent = el.offsetParent || root.value;
81
+ const rect = el.getBoundingClientRect();
82
+ const pr = parent.getBoundingClientRect();
83
+ const scale = pr.width / parent.offsetWidth || 1;
84
+ gesture = { id: props.modelValue, pointer: event.pointerId, handle: event.currentTarget, x: event.clientX, y: event.clientY, scale, resize, left: (rect.left - pr.left) / scale - parent.clientLeft + parent.scrollLeft, top: (rect.top - pr.top) / scale - parent.clientTop + parent.scrollTop, width: rect.width / scale, height: rect.height / scale };
85
+ gesture.element = el;
86
+ if (props.snapping || (props.spacingSnapping && !resize)) {
87
+ const stage = root.value.getBoundingClientRect();
88
+ const stageScale = stage.width / root.value.offsetWidth || 1;
89
+ const origin = { left: (rect.left - stage.left) / stageScale - root.value.clientLeft + root.value.scrollLeft, top: (rect.top - stage.top) / stageScale - root.value.clientTop + root.value.scrollTop };
90
+ const scope = props.frameScoped ? containingFrame(el) : null;
91
+ const scopeRect = scope?.getBoundingClientRect();
92
+ const rects = [scope ? { left: (scopeRect.left - stage.left) / stageScale - root.value.clientLeft + root.value.scrollLeft + scope.clientLeft, top: (scopeRect.top - stage.top) / stageScale - root.value.clientTop + root.value.scrollTop + scope.clientTop, width: scope.clientWidth, height: scope.clientHeight } : { left: 0, top: 0, width: root.value.clientWidth, height: root.value.clientHeight }];
93
+ const bounds = rects[0];
94
+ guideClip.value = scope ? `inset(${bounds.top}px ${Math.max(0, root.value.clientWidth - bounds.left - bounds.width)}px ${Math.max(0, root.value.clientHeight - bounds.top - bounds.height)}px ${bounds.left}px)` : null;
95
+ for (const target of root.value.querySelectorAll('[data-designer-id]')) {
96
+ if (target === el || el.contains(target) || target.contains(el)) continue;
97
+ if (scope && target.parentElement !== scope) continue;
98
+ const r = target.getBoundingClientRect();
99
+ const style = target.ownerDocument.defaultView.getComputedStyle(target);
100
+ if (!r.width || !r.height || style.visibility === 'hidden' || style.display === 'none') continue;
101
+ const left = (r.left - stage.left) / stageScale - root.value.clientLeft + root.value.scrollLeft;
102
+ const top = (r.top - stage.top) / stageScale - root.value.clientTop + root.value.scrollTop;
103
+ if (left + r.width / stageScale < 0 || top + r.height / stageScale < 0 || left > root.value.clientWidth || top > root.value.clientHeight) continue;
104
+ rects.push({ left, top, width: r.width / stageScale, height: r.height / stageScale });
105
+ }
106
+ gesture.snap = {
107
+ index: props.snapping ? createSnapIndex(rects) : null,
108
+ spacing: props.spacingSnapping && !resize ? createSpacingIndex(rects.slice(1)) : null,
109
+ origin, scale: stageScale, matches: {},
110
+ };
111
+ }
112
+ event.currentTarget.setPointerCapture(event.pointerId);
113
+ }
114
+ /** Coalesces pointer events to one geometry update per animation frame. */
115
+ function move(event) {
116
+ if (!gesture || gesture.pointer !== event.pointerId) return;
117
+ pendingMove = { pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY, ctrlKey: event.ctrlKey };
118
+ if (!moveFrame) moveFrame = requestAnimationFrame(flushMove);
119
+ }
120
+ /** Applies the latest pointer sample without retaining browser event objects. */
121
+ function flushMove() {
122
+ moveFrame = null;
123
+ const event = pendingMove;
124
+ pendingMove = null;
125
+ if (event) applyMove(event);
126
+ }
127
+ /** Emits CSS patches in parent coordinates after optional stage-coordinate snapping. */
128
+ function applyMove(event) {
129
+ if (!gesture || gesture.pointer !== event.pointerId) return;
130
+ const g = gesture;
131
+ const dx = (event.clientX - g.x) / g.scale;
132
+ const dy = (event.clientY - g.y) / g.scale;
133
+ if (!g.moved && Math.abs(dx) + Math.abs(dy) < 2) return;
134
+ g.moved = true;
135
+ const proposed = g.resize ? resizeBounds(g, g.resize, dx, dy) : { left: g.left + dx, top: g.top + dy, width: g.width, height: g.height };
136
+ let { left, top, width, height } = proposed;
137
+ if (g.snap && (props.snapping || (props.spacingSnapping && !g.resize)) && !event.ctrlKey) {
138
+ const snap = g.snap;
139
+ const ratio = g.scale / snap.scale;
140
+ const result = g.resize
141
+ ? snapResize({ left: snap.origin.left + (left - g.left) * ratio, top: snap.origin.top + (top - g.top) * ratio, width: width * ratio, height: height * ratio }, snap.index, snap.scale, snap.matches, 16 * ratio, g.resize)
142
+ : snapTranslation({ left: snap.origin.left + dx * ratio, top: snap.origin.top + dy * ratio, width: g.width * ratio, height: g.height * ratio }, { alignment: snap.index, spacing: snap.spacing }, snap.scale, snap.matches);
143
+ if (g.resize) {
144
+ width = result.width / ratio;
145
+ height = result.height / ratio;
146
+ }
147
+ left = g.left + (result.left - snap.origin.left) / ratio;
148
+ top = g.top + (result.top - snap.origin.top) / ratio;
149
+ snap.matches = result.matches;
150
+ guides.value = result.matches;
151
+ } else {
152
+ guides.value = {};
153
+ if (g.snap) g.snap.matches = {};
154
+ }
155
+ const style = { position: 'absolute', margin: '0px', boxSizing: 'border-box', left: `${Math.round(left)}px`, top: `${Math.round(top)}px`, width: `${Math.max(16, Math.round(width))}px`, height: `${Math.max(16, Math.round(height))}px` };
156
+ emit('style-change', { id: g.id, style });
157
+ nextTick(measure);
158
+ }
159
+ /** Flushes the last queued sample before a normal pointer release. */
160
+ function finish() {
161
+ if (moveFrame) cancelAnimationFrame(moveFrame);
162
+ flushMove();
163
+ end();
164
+ }
165
+ /** Releases capture on completion, cancellation, selection change or unmount. */
166
+ function end() {
167
+ if (moveFrame) cancelAnimationFrame(moveFrame);
168
+ moveFrame = null;
169
+ pendingMove = null;
170
+ guides.value = {};
171
+ guideClip.value = null;
172
+ const g = gesture;
173
+ gesture = null;
174
+ if (g?.handle.hasPointerCapture(g.pointer)) g.handle.releasePointerCapture(g.pointer);
175
+ }
176
+ /** Provides keyboard movement and resizing for the same selected HTML layer. */
177
+ function nudge(event, handle = '') {
178
+ if (!props.positioning || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
179
+ const el = selectedElement();
180
+ if (!el || !permitsPositioning(el)) return;
181
+ event.preventDefault();
182
+ const step = event.shiftKey ? 10 : 1;
183
+ const horizontal = event.key === 'ArrowLeft' || event.key === 'ArrowRight';
184
+ const delta = ['ArrowLeft', 'ArrowUp'].includes(event.key) ? -step : step;
185
+ const key = event.altKey ? (horizontal ? 'width' : 'height') : (horizontal ? 'left' : 'top');
186
+ const parent = el.offsetParent || root.value;
187
+ const rect = el.getBoundingClientRect();
188
+ const pr = parent.getBoundingClientRect();
189
+ const scale = pr.width / parent.offsetWidth || 1;
190
+ const geometry = { left: (rect.left - pr.left) / scale - parent.clientLeft + parent.scrollLeft, top: (rect.top - pr.top) / scale - parent.clientTop + parent.scrollTop, width: rect.width / scale, height: rect.height / scale };
191
+ if (handle) Object.assign(geometry, resizeBounds(geometry, handle, horizontal ? delta : 0, horizontal ? 0 : delta));
192
+ else geometry[key] = event.altKey ? Math.max(16, geometry[key] + delta) : geometry[key] + delta;
193
+ emit('style-change', { id: props.modelValue, style: { position: 'absolute', margin: '0px', boxSizing: 'border-box', ...Object.fromEntries(Object.entries(geometry).map(([name, value]) => [name, `${Math.round(value)}px`])) } });
194
+ }
195
+ /** Installs observers for responsive HTML, font loading and application-owned edits. */
196
+ function mount() {
197
+ observer = new ResizeObserver(measure);
198
+ observer.observe(root.value);
199
+ mutationObserver = new MutationObserver(refresh);
200
+ mutationObserver.observe(root.value, { childList: true, subtree: true, attributes: true, characterData: true });
201
+ window.addEventListener('resize', measure);
202
+ refresh();
203
+ }
204
+ /** Updates observed layer boxes while ignoring mutations to the editor overlay itself. */
205
+ function refresh(records = []) {
206
+ if (records.length && records.every((r) => r.target.closest?.('[data-designer-overlay]'))) return;
207
+ if (gesture) { measure(); return; }
208
+ observer?.disconnect();
209
+ if (root.value) {
210
+ observer?.observe(root.value);
211
+ root.value.querySelectorAll('[data-designer-id]').forEach((el) => observer?.observe(el));
212
+ }
213
+ measure();
214
+ }
215
+ /** Tears down every observer, listener and pointer capture owned by this stage. */
216
+ function cleanup() {
217
+ end();
218
+ observer?.disconnect();
219
+ mutationObserver?.disconnect();
220
+ window.removeEventListener('resize', measure);
221
+ }
222
+ watch(() => [props.modelValue, props.positioning, props.interactive, props.snapping, props.spacingSnapping, props.frameScoped], async () => { end(); await nextTick(); measure(); });
223
+ onMounted(mount);
224
+ onBeforeUnmount(cleanup);
225
+ </script>
226
+
227
+ <template>
228
+ <div ref="root" class="relative isolate" @click.capture="select" @scroll.capture="measure">
229
+ <slot />
230
+ <div data-designer-overlay aria-hidden="true" class="pointer-events-none absolute inset-0 z-[2147483647]" :style="{ clipPath: guideClip }">
231
+ <div v-if="guides.x && guides.x.kind !== 'spacing'" class="absolute inset-y-0 bg-primary" :style="{ left: `${guides.x.target}px`, width: `${1 / viewScale}px` }" />
232
+ <div v-if="guides.y && guides.y.kind !== 'spacing'" class="absolute inset-x-0 bg-primary" :style="{ top: `${guides.y.target}px`, height: `${1 / viewScale}px` }" />
233
+ <SpacingGuides :matches="guides" :scale="viewScale" />
234
+ </div>
235
+ <DesignerBounds v-if="box && !interactive" :box="box" :scale="viewScale" :positioning="canPosition" @begin="begin($event.event, $event.handle)" @move="move" @finish="finish" @cancel="end" @nudge="nudge($event.event, $event.handle)" />
236
+ </div>
237
+ </template>
@@ -0,0 +1,33 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+ const props = defineProps({ matches: { type: Object, default: () => ({}) }, scale: { type: Number, default: 1 } });
4
+ const unit = computed(() => 1 / Math.max(props.scale, 0.001));
5
+ const measures = computed(measurements);
6
+
7
+ /** Projects the reference and repeated gaps into non-interactive stage-coordinate rulers. */
8
+ function measurements() {
9
+ return ['x', 'y'].flatMap((axis) => {
10
+ const match = props.matches[axis];
11
+ if (match?.kind !== 'spacing') return [];
12
+ return [match.reference, [match.neighbour, match.target]].map(([from, to]) => ({
13
+ axis, from: Math.min(from, to), to: Math.max(from, to), cross: match.cross,
14
+ label: `${Math.round(match.gap * 100) / 100}px`,
15
+ }));
16
+ });
17
+ }
18
+ </script>
19
+
20
+ <template>
21
+ <svg v-if="measures.length" class="absolute inset-0 h-full w-full overflow-visible text-primary" aria-hidden="true">
22
+ <g v-for="(measure, index) in measures" :key="index" :stroke-width="unit" stroke="currentColor" fill="none">
23
+ <template v-if="measure.axis === 'x'">
24
+ <path :d="`M${measure.from},${measure.cross}H${measure.to} M${measure.from},${measure.cross - 4 * unit}v${8 * unit} M${measure.to},${measure.cross - 4 * unit}v${8 * unit}`" />
25
+ <text :x="(measure.from + measure.to) / 2" :y="measure.cross - 7 * unit" text-anchor="middle" :font-size="11 * unit" :stroke-width="3 * unit" stroke="var(--canvas)" fill="currentColor" paint-order="stroke">{{ measure.label }}</text>
26
+ </template>
27
+ <template v-else>
28
+ <path :d="`M${measure.cross},${measure.from}V${measure.to} M${measure.cross - 4 * unit},${measure.from}h${8 * unit} M${measure.cross - 4 * unit},${measure.to}h${8 * unit}`" />
29
+ <text :x="measure.cross + 7 * unit" :y="(measure.from + measure.to) / 2" dominant-baseline="middle" :font-size="11 * unit" :stroke-width="3 * unit" stroke="var(--canvas)" fill="currentColor" paint-order="stroke">{{ measure.label }}</text>
30
+ </template>
31
+ </g>
32
+ </svg>
33
+ </template>
@@ -0,0 +1,15 @@
1
+ /** Resizes only the handle's axes, preserving opposite edges and clamping before they cross. */
2
+ export function resizeBounds(rect, handle, dx, dy, minimum = 16) {
3
+ const result = { ...rect };
4
+ if (handle.includes('e')) result.width = Math.max(minimum, rect.width + dx);
5
+ if (handle.includes('s')) result.height = Math.max(minimum, rect.height + dy);
6
+ if (handle.includes('w')) {
7
+ result.width = Math.max(minimum, rect.width - dx);
8
+ result.left = rect.left + rect.width - result.width;
9
+ }
10
+ if (handle.includes('n')) {
11
+ result.height = Math.max(minimum, rect.height - dy);
12
+ result.top = rect.top + rect.height - result.height;
13
+ }
14
+ return result;
15
+ }
@@ -0,0 +1,69 @@
1
+ import { resizeBounds } from './resizeGeometry.js';
2
+
3
+ /** Builds sorted, deduplicated edge/centre indexes once per gesture in stage coordinates. */
4
+ export function createSnapIndex(rects) {
5
+ return {
6
+ x: [...new Set(rects.flatMap((r) => [r.left, r.left + r.width / 2, r.left + r.width]))].sort((a, b) => a - b),
7
+ y: [...new Set(rects.flatMap((r) => [r.top, r.top + r.height / 2, r.top + r.height]))].sort((a, b) => a - b),
8
+ };
9
+ }
10
+
11
+ /** Finds a nearest coordinate in logarithmic time without scanning all alignment targets. */
12
+ function nearest(values, value) {
13
+ let low = 0;
14
+ let high = values.length;
15
+ while (low < high) {
16
+ const middle = (low + high) >>> 1;
17
+ if (values[middle] < value) low = middle + 1;
18
+ else high = middle;
19
+ }
20
+ const before = values[low - 1];
21
+ const after = values[low];
22
+ if (before === undefined) return after;
23
+ if (after === undefined) return before;
24
+ return value - before <= after - value ? before : after;
25
+ }
26
+
27
+ /** Resolves one axis, retaining its active anchor until the larger release threshold is crossed. */
28
+ function snapAxis(start, size, targets, threshold, previous) {
29
+ const anchors = [start, start + size / 2, start + size];
30
+ if (previous && Math.abs(previous.target - anchors[previous.anchor]) <= threshold * 5 / 3) {
31
+ return { delta: previous.target - anchors[previous.anchor], match: previous };
32
+ }
33
+ let best = { delta: 0, match: null };
34
+ let distance = threshold;
35
+ for (let anchor = 0; anchor < anchors.length; anchor++) {
36
+ const target = nearest(targets, anchors[anchor]);
37
+ if (target === undefined) continue;
38
+ const delta = target - anchors[anchor];
39
+ if (Math.abs(delta) <= distance) {
40
+ distance = Math.abs(delta);
41
+ best = { delta, match: { target, anchor } };
42
+ }
43
+ }
44
+ return best;
45
+ }
46
+
47
+ /** Snaps translation independently on each axis; thresholds remain constant in screen pixels. */
48
+ export function snapPosition(rect, index, scale = 1, previous = {}) {
49
+ const threshold = 6 / Math.max(scale, 0.001);
50
+ const x = snapAxis(rect.left, rect.width, index.x, threshold, previous.x);
51
+ const y = snapAxis(rect.top, rect.height, index.y, threshold, previous.y);
52
+ return { left: rect.left + x.delta, top: rect.top + y.delta, matches: { x: x.match, y: y.match } };
53
+ }
54
+
55
+ /** Snaps only the dragged edges, preserving opposite edges and rejecting sub-minimum matches. */
56
+ export function snapResize(rect, index, scale = 1, previous = {}, minimum = 16, handle = 'se') {
57
+ const threshold = 6 / Math.max(scale, 0.001);
58
+ const base = resizeBounds(rect, handle, 0, 0, minimum);
59
+ const horizontal = handle.includes('w') || handle.includes('e');
60
+ const vertical = handle.includes('n') || handle.includes('s');
61
+ const x = horizontal ? snapAxis(base.left + (handle.includes('w') ? 0 : base.width), 0, index.x, threshold, previous.x) : { delta: 0, match: null };
62
+ const y = vertical ? snapAxis(base.top + (handle.includes('n') ? 0 : base.height), 0, index.y, threshold, previous.y) : { delta: 0, match: null };
63
+ const validX = base.width + (handle.includes('w') ? -x.delta : x.delta) >= minimum;
64
+ const validY = base.height + (handle.includes('n') ? -y.delta : y.delta) >= minimum;
65
+ return {
66
+ ...resizeBounds(base, handle, validX ? x.delta : 0, validY ? y.delta : 0, minimum),
67
+ matches: { x: validX ? x.match : null, y: validY ? y.match : null },
68
+ };
69
+ }
@@ -0,0 +1,17 @@
1
+ import { snapPosition } from './snapGeometry.js';
2
+ import { snapSpacing } from './spacingGeometry.js';
3
+
4
+ /**
5
+ * Runs independent translation snap stages in priority order. Missing indexes
6
+ * disable a stage entirely. Alignment resolves first; equal spacing only fills
7
+ * free axes, so enabling it cannot replace an existing alignment match.
8
+ */
9
+ export function snapTranslation(rect, indexes, scale = 1, previous = {}) {
10
+ const alignmentPrevious = { x: previous.x?.kind === 'spacing' ? null : previous.x, y: previous.y?.kind === 'spacing' ? null : previous.y };
11
+ const aligned = indexes.alignment
12
+ ? snapPosition(rect, indexes.alignment, scale, alignmentPrevious)
13
+ : { left: rect.left, top: rect.top, matches: { x: null, y: null } };
14
+ if (!indexes.spacing) return aligned;
15
+ const spaced = snapSpacing({ ...rect, left: aligned.left, top: aligned.top }, indexes.spacing, scale, previous, aligned.matches);
16
+ return { left: spaced.left, top: spaced.top, matches: { x: aligned.matches.x || spaced.matches.x, y: aligned.matches.y || spaced.matches.y } };
17
+ }
@@ -0,0 +1,129 @@
1
+ /** Returns the nearer forward neighbour, with stable ordering for equal starts. */
2
+ function nearer(a, b) {
3
+ if (!a) return b;
4
+ if (!b) return a;
5
+ return a.start <= b.start ? a : b;
6
+ }
7
+
8
+ /**
9
+ * Finds adjacent, non-overlapping pairs sharing a perpendicular interval.
10
+ * An offline sweep and interval minimum tree avoid enumerating all layer pairs:
11
+ * O(n log n) construction, with at most one forward neighbour per layer/axis.
12
+ */
13
+ function adjacentPairs(rects, axis) {
14
+ const items = rects.map((rect) => axis === 'x'
15
+ ? { start: rect.left, end: rect.left + rect.width, low: rect.top, high: rect.top + rect.height }
16
+ : { start: rect.top, end: rect.top + rect.height, low: rect.left, high: rect.left + rect.width });
17
+ const points = [...new Set(items.flatMap((item) => [item.low, item.high]))].sort((a, b) => a - b);
18
+ const positions = new Map(points.map((point, index) => [point, index]));
19
+ const count = points.length - 1;
20
+ if (count < 1) return [];
21
+ const best = [];
22
+ const cover = [];
23
+ /** Adds a candidate across its perpendicular interval without visiting individual slots. */
24
+ function insert(node, low, high, from, to, item) {
25
+ if (to < low || from > high) return;
26
+ best[node] = nearer(best[node], item);
27
+ if (from <= low && to >= high) { cover[node] = nearer(cover[node], item); return; }
28
+ const middle = (low + high) >>> 1;
29
+ insert(node * 2, low, middle, from, to, item);
30
+ insert(node * 2 + 1, middle + 1, high, from, to, item);
31
+ }
32
+ /** Queries the nearest candidate overlapping any part of the requested interval. */
33
+ function query(node, low, high, from, to) {
34
+ if (to < low || from > high) return null;
35
+ if (from <= low && to >= high) return best[node];
36
+ const middle = (low + high) >>> 1;
37
+ return nearer(cover[node], nearer(query(node * 2, low, middle, from, to), query(node * 2 + 1, middle + 1, high, from, to)));
38
+ }
39
+ const starts = [...items].sort((a, b) => b.start - a.start);
40
+ const ends = [...items].sort((a, b) => b.end - a.end);
41
+ const pairs = [];
42
+ let cursor = 0;
43
+ for (const item of ends) {
44
+ while (cursor < starts.length && starts[cursor].start >= item.end) {
45
+ const next = starts[cursor++];
46
+ insert(1, 0, count - 1, positions.get(next.low), positions.get(next.high) - 1, next);
47
+ }
48
+ const next = query(1, 0, count - 1, positions.get(item.low), positions.get(item.high) - 1);
49
+ if (next && next.start > item.end) pairs.push([item, next]);
50
+ }
51
+ return pairs;
52
+ }
53
+
54
+ /**
55
+ * Snapshots repeated-gap extension targets from visible peer layers only.
56
+ * The stage and dragged layer must not be passed in. Targets remain immutable
57
+ * for the gesture; before-pair targets use the moving trailing edge, after-pair
58
+ * targets use its leading edge, so different layer sizes work naturally.
59
+ */
60
+ export function createSpacingIndex(rects) {
61
+ const valid = rects.filter((r) => [r.left, r.top, r.width, r.height].every(Number.isFinite) && r.width > 0 && r.height > 0);
62
+ const index = {};
63
+ for (const axis of ['x', 'y']) {
64
+ const before = [];
65
+ const after = [];
66
+ for (const [a, b] of adjacentPairs(valid, axis)) {
67
+ const gap = b.start - a.end;
68
+ const shared = { kind: 'spacing', gap, low: Math.max(a.low, b.low), high: Math.min(a.high, b.high), reference: [a.end, b.start] };
69
+ before.push({ ...shared, target: a.start - gap, anchor: 2, neighbour: a.start });
70
+ after.push({ ...shared, target: b.end + gap, anchor: 0, neighbour: b.end });
71
+ }
72
+ index[axis] = [after.sort((a, b) => a.target - b.target), before.sort((a, b) => a.target - b.target)];
73
+ }
74
+ return index;
75
+ }
76
+
77
+ /** Returns the first target not below a search boundary. */
78
+ function lowerBound(targets, value) {
79
+ let low = 0;
80
+ let high = targets.length;
81
+ while (low < high) {
82
+ const middle = (low + high) >>> 1;
83
+ if (targets[middle].target < value) low = middle + 1;
84
+ else high = middle;
85
+ }
86
+ return low;
87
+ }
88
+
89
+ /** Requires the moving layer to share the pair's row or column, not merely a remote gap value. */
90
+ function overlaps(match, low, high) {
91
+ return Math.min(match.high, high) > Math.max(match.low, low);
92
+ }
93
+
94
+ /** Resolves one translation axis with six-pixel acquisition and ten-pixel release at any zoom. */
95
+ function resolveAxis(start, size, low, high, targets, threshold, previous) {
96
+ let match = null;
97
+ let distance = threshold;
98
+ if (previous?.kind === 'spacing' && overlaps(previous, low, high)
99
+ && Math.abs(previous.target - start - (previous.anchor === 2 ? size : 0)) <= threshold * 5 / 3) match = previous;
100
+ if (!match) {
101
+ for (let side = 0; side < 2; side++) {
102
+ const value = start + (side ? size : 0);
103
+ const list = targets[side];
104
+ for (let i = lowerBound(list, value - threshold); i < list.length && list[i].target <= value + threshold; i++) {
105
+ const candidate = list[i];
106
+ const delta = Math.abs(candidate.target - value);
107
+ if (delta <= distance && overlaps(candidate, low, high)) { match = candidate; distance = delta; }
108
+ }
109
+ }
110
+ }
111
+ if (!match) return { position: start, match: null };
112
+ return {
113
+ position: match.target - (match.anchor === 2 ? size : 0),
114
+ match: { ...match, cross: (Math.max(match.low, low) + Math.min(match.high, high)) / 2 },
115
+ };
116
+ }
117
+
118
+ /** Fills only unlocked translation axes; alignment matches always keep precedence. */
119
+ export function snapSpacing(rect, index, scale = 1, previous = {}, locked = {}) {
120
+ const threshold = 6 / Math.max(scale, 0.001);
121
+ const x = locked.x ? { position: rect.left, match: null } : resolveAxis(rect.left, rect.width, rect.top, rect.top + rect.height, index.x, threshold, previous.x);
122
+ const y = locked.y ? { position: rect.top, match: null } : resolveAxis(rect.top, rect.height, rect.left, rect.left + rect.width, index.y, threshold, previous.y);
123
+ // A correction on the other axis must not take this layer out of the matched row/column.
124
+ if (x.match && !overlaps(x.match, y.position, y.position + rect.height)) { x.position = rect.left; x.match = null; }
125
+ if (y.match && !overlaps(y.match, x.position, x.position + rect.width)) { y.position = rect.top; y.match = null; }
126
+ if (x.match) x.match.cross = (Math.max(x.match.low, y.position) + Math.min(x.match.high, y.position + rect.height)) / 2;
127
+ if (y.match) y.match.cross = (Math.max(y.match.low, x.position) + Math.min(y.match.high, x.position + rect.width)) / 2;
128
+ return { left: x.position, top: y.position, matches: { x: x.match, y: y.match } };
129
+ }
@@ -38,10 +38,15 @@ const props = defineProps({
38
38
  },
39
39
  });
40
40
 
41
- const emit = defineEmits(['update:modelValue']);
41
+ const emit = defineEmits(['update:modelValue', 'focus', 'blur']);
42
+ /** @param {string|object} option Select option. @returns {string} Visible option label. */
42
43
  const labelOf = (option) => (option && typeof option === 'object' ? (option.label ?? option.value) : option);
44
+ /** @param {string|object} option Select option. @returns {unknown} Submitted option value. */
43
45
  const valueOf = (option) => (option && typeof option === 'object' ? (option.value ?? option.label) : option);
44
46
  const field = useField(props, emit, { idPrefix: 'dom-native-select' });
47
+
48
+ /** @param {unknown} value Option value. @returns {boolean} Explicit selection for SSR and initial hydration. */
49
+ function isSelected(value) { return String(field.value.value ?? '') === String(value ?? ''); }
45
50
  </script>
46
51
 
47
52
  <template>
@@ -54,8 +59,8 @@ const field = useField(props, emit, { idPrefix: 'dom-native-select' });
54
59
  @focus="field.onFocus"
55
60
  @blur="field.onBlur"
56
61
  >
57
- <option v-if="placeholder" disabled value="">{{ placeholder }}</option>
58
- <option v-for="option in options" :key="valueOf(option)" :value="valueOf(option)">
62
+ <option v-if="placeholder" disabled value="" :selected="isSelected('')">{{ placeholder }}</option>
63
+ <option v-for="option in options" :key="valueOf(option)" :value="valueOf(option)" :selected="isSelected(valueOf(option))">
59
64
  {{ labelOf(option) }}
60
65
  </option>
61
66
  </select>
@@ -3,6 +3,30 @@ import DomField from '../field/DomField.vue';
3
3
  import { fieldProps } from '../field/fieldProps.js';
4
4
  import { useField } from '../field/useField.js';
5
5
 
6
+ defineOptions({
7
+ __doc: {
8
+ name: 'Vue number input',
9
+ tag: '<DomNumberInput>',
10
+ description: 'An accessible Vue 3 number input with v-model, labels, validation, and native min, max, and step constraints.',
11
+ icon: 'M8 7h8M8 12h8M8 17h8',
12
+ events: [
13
+ { name: 'update:modelValue', payload: 'number | string', description: 'Fired with a number as the user types, or an empty string when cleared.' },
14
+ { name: 'focus', payload: 'FocusEvent', description: 'Fired when the number input receives focus.' },
15
+ { name: 'blur', payload: 'FocusEvent', description: 'Fired when the number input loses focus.' },
16
+ ],
17
+ playground: {
18
+ initial: {
19
+ label: 'Quantity',
20
+ description: 'Choose between 1 and 99 items.',
21
+ modelValue: 3,
22
+ min: 1,
23
+ max: 99,
24
+ step: 1,
25
+ },
26
+ },
27
+ },
28
+ });
29
+
6
30
  const props = defineProps({
7
31
  ...fieldProps,
8
32
  modelValue: {
@@ -10,9 +34,21 @@ const props = defineProps({
10
34
  default: '',
11
35
  _edit: { description: 'Current numeric value.' },
12
36
  },
13
- min: Number,
14
- max: Number,
15
- step: Number,
37
+ min: {
38
+ type: Number,
39
+ default: null,
40
+ _edit: { description: 'Minimum accepted number and native spinbutton boundary.' },
41
+ },
42
+ max: {
43
+ type: Number,
44
+ default: null,
45
+ _edit: { description: 'Maximum accepted number and native spinbutton boundary.' },
46
+ },
47
+ step: {
48
+ type: Number,
49
+ default: 1,
50
+ _edit: { description: 'Increment used by the browser spin buttons and arrow keys.' },
51
+ },
16
52
  });
17
53
  const emit = defineEmits(['update:modelValue', 'focus', 'blur']);
18
54
  const field = useField(props, emit, { idPrefix: 'dom-number-input' });