@getdom/studio 0.1.1 → 0.2.0
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/CHANGELOG.md +31 -0
- package/README.md +31 -0
- package/SKILL.md +21 -1
- package/package.json +4 -2
- package/public/llms.txt +13 -2
- package/src/pages/_layout/componentCatalog.generated.js +1185 -44
- package/src/pages/_layout/componentLoaders.js +41 -0
- package/src/pages/_layout/componentManager.js +38 -27
- package/src/pages/_layout/componentRuntimeRecords.js +3 -1
- package/src/pages/charts/_shared/ChartContainer.vue +67 -0
- package/src/pages/charts/_shared/ChartLegend.vue +22 -0
- package/src/pages/charts/_shared/ChartTooltip.vue +18 -0
- package/src/pages/charts/_shared/DonutRenderer.vue +33 -0
- package/src/pages/charts/_shared/XYChart.vue +140 -0
- package/src/pages/charts/_shared/XYRenderer.vue +90 -0
- package/src/pages/charts/_shared/chartProps.js +23 -0
- package/src/pages/charts/_shared/chartUtils.js +95 -0
- package/src/pages/charts/area-chart/DomAreaChart.vue +27 -0
- package/src/pages/charts/bar-chart/DomBarChart.vue +29 -0
- package/src/pages/charts/date-range-picker/DomDateRangePicker.vue +104 -0
- package/src/pages/charts/date-range-picker/dateRange.js +69 -0
- package/src/pages/charts/donut-chart/DomDonutChart.vue +117 -0
- package/src/pages/charts/line-chart/DomLineChart.vue +26 -0
- package/src/pages/charts/metric-card/DomMetricCard.vue +49 -0
- package/src/pages/charts/sparkline/DomSparkline.vue +30 -0
- package/src/pages/components/_shared/media/MediaDeviceSelect.vue +28 -0
- package/src/pages/components/_shared/media/useMediaInput.js +200 -0
- package/src/pages/components/audio-controls/DomAudioControls.vue +91 -0
- package/src/pages/components/audio-input/DomAudioInput.vue +58 -0
- package/src/pages/components/button/DomButton.vue +10 -4
- package/src/pages/components/button/buttonStyles.js +5 -0
- package/src/pages/components/camera-input/DomCameraInput.vue +75 -0
- package/src/pages/components/command-palette/DomCommandPalette.vue +12 -2
- package/src/pages/components/dialog/DomDialog.vue +338 -36
- package/src/pages/components/icon-button/DomIconButton.vue +8 -2
- package/src/pages/components/menu-stack/DomMenuStack.vue +111 -0
- package/src/pages/components/menu-stack/menuTree.js +35 -0
- package/src/pages/components/popover/DomPopover.vue +1 -53
- package/src/pages/components/popover/popover.css +50 -0
- package/src/pages/components/video-feed/DomVideoFeed.vue +551 -0
- package/src/pages/data/agent-chat/DomAgentChatShell.vue +1 -0
- package/src/pages/data/agent-chat/DomAgentConversation.vue +1 -0
- package/src/pages/data/agent-chat/DomAgentConversationList.vue +1 -0
- package/src/pages/data/gantt/DomGantt.vue +788 -0
- package/src/pages/data/gantt/ganttTime.js +223 -0
- package/src/pages/data/month-calendar/DomMonthCalendar.vue +84 -9
- package/src/pages/data/notification-inbox/DomNotificationInbox.vue +505 -0
- package/src/pages/data/notification-inbox/notificationPresentation.js +34 -0
- package/src/pages/designer/_shared/ControlIconButton.vue +62 -0
- package/src/pages/designer/_shared/StyleControlSection.vue +59 -0
- package/src/pages/designer/_shared/cssEditorContext.js +19 -0
- package/src/pages/designer/backdrop-editor/BackdropControl.vue +57 -0
- package/src/pages/designer/backdrop-editor/DomBackdropEditor.vue +33 -0
- package/src/pages/designer/backdrop-editor/backdropModel.js +39 -0
- package/src/pages/designer/background-editor/BackgroundLayers.vue +210 -0
- package/src/pages/designer/background-editor/DomBackgroundEditor.vue +33 -0
- package/src/pages/designer/background-editor/backgroundModel.js +102 -0
- package/src/pages/designer/border-editor/BorderControl.vue +72 -0
- package/src/pages/designer/border-editor/DomBorderEditor.vue +34 -0
- package/src/pages/designer/border-editor/borderModel.js +21 -0
- package/src/pages/designer/color-swatch/DomColorSwatch.vue +95 -0
- package/src/pages/designer/frame/DomDesignerFrame.vue +16 -0
- package/src/pages/designer/frame/DomFrameLayoutEditor.vue +41 -0
- package/src/pages/designer/frame/frameLayout.js +93 -0
- package/src/pages/designer/layers/DomDesignerLayers.vue +11 -0
- package/src/pages/designer/shadow-editor/DomShadowEditor.vue +32 -0
- package/src/pages/designer/shadow-editor/ShadowLayers.vue +185 -0
- package/src/pages/designer/shadow-editor/shadowModel.js +73 -0
- package/src/pages/designer/stage/DesignerBounds.vue +31 -0
- package/src/pages/designer/stage/DomDesignerStage.vue +237 -0
- package/src/pages/designer/stage/SpacingGuides.vue +33 -0
- package/src/pages/designer/stage/resizeGeometry.js +15 -0
- package/src/pages/designer/stage/snapGeometry.js +69 -0
- package/src/pages/designer/stage/snapPipeline.js +17 -0
- package/src/pages/designer/stage/spacingGeometry.js +129 -0
- package/src/pages/forms/native-select/DomNativeSelect.vue +8 -3
- package/src/pages/forms/number-input/DomNumberInput.vue +39 -3
- package/src/pages/forms/range-input/DomRangeInput.vue +75 -23
- package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +91 -9
- package/src/pages/forms/toggle/DomToggle.vue +1 -1
- package/src/pages/lib/headless/base.js +10 -5
- package/src/pages/lib/headless/dialog.js +25 -6
- package/src/pages/lib/headless/drawer.js +49 -21
- package/src/pages/lib/vue/index.d.ts +1161 -14
- package/src/pages/lib/vue/index.js +43 -0
- package/src/pages/mobile/_shared/interactionMath.js +46 -0
- package/src/pages/mobile/_shared/useKeyboardViewport.js +91 -0
- package/src/pages/mobile/action-sheet/DomActionSheet.vue +102 -65
- package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +4 -2
- package/src/pages/mobile/bottom-sheet/DomBottomSheet.vue +221 -0
- package/src/pages/mobile/list-item/DomAppListItem.vue +6 -6
- package/src/pages/mobile/profile/DomAppProfile.vue +42 -0
- package/src/pages/mobile/safe-area/DomSafeArea.vue +6 -6
- package/src/pages/mobile/shell/DomAppShell.vue +60 -9
- package/src/pages/mobile/stack/DomAppStack.vue +214 -55
- package/src/pages/mobile/swipe-actions/DomSwipeActions.vue +346 -0
- package/src/pages/mobile/top-bar/DomAppTopBar.vue +3 -3
- package/src/pages/visual/audio-visualizer/DomAudioVisualizer.vue +143 -0
- package/src/theme.css +13 -0
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/** Public field mappings and display options for the shared scheduling surface. */
|
|
3
|
+
export const ganttProps = {
|
|
4
|
+
rows: { type: Array, default: () => [], _edit: { description: 'Application-owned people, projects, or feature rows.' } },
|
|
5
|
+
items: { type: Array, default: () => [], _edit: { description: 'Application-owned scheduled items. Multiple items may share a row.' } },
|
|
6
|
+
rowKey: { type: [String, Function], default: 'id' },
|
|
7
|
+
rowLabelKey: { type: [String, Function], default: 'name' },
|
|
8
|
+
itemKey: { type: [String, Function], default: 'id' },
|
|
9
|
+
itemRowKey: { type: [String, Function], default: 'rowId' },
|
|
10
|
+
titleKey: { type: [String, Function], default: 'title' },
|
|
11
|
+
startKey: { type: [String, Function], default: 'startDate' },
|
|
12
|
+
endKey: { type: [String, Function], default: 'endDate' },
|
|
13
|
+
durationKey: { type: [String, Function], default: 'duration' },
|
|
14
|
+
toneKey: { type: [String, Function], default: 'tone' },
|
|
15
|
+
durationUnit: { type: String, default: 'days', _edit: { options: ['days', 'hours', 'minutes'], description: 'Whole local calendar days, or elapsed hours/minutes. An explicit end wins.' } },
|
|
16
|
+
startDate: { type: [String, Date, Number], default: '', _edit: { description: 'First local date of the scrollable range. Supply a stable value for SSR.' } },
|
|
17
|
+
days: { type: Number, default: 84, _edit: { description: 'Scrollable range in calendar days, from 1 to 3660. Change this or startDate to load another window.' } },
|
|
18
|
+
zoom: { type: String, default: 'weeks', _edit: { options: ['months', 'weeks', 'days'], description: 'Initial or v-model zoom. Days displays individual hours.' } },
|
|
19
|
+
planMode: { type: Boolean, default: false, _edit: { description: 'Enable named row creation and pointer/keyboard time selection.' } },
|
|
20
|
+
draggableItems: { type: Boolean, default: false, _edit: { description: 'Allow items to move through time. Emits item-move; the application saves the change.' } },
|
|
21
|
+
allowRowChange: { type: Boolean, default: false, _edit: { description: 'Allow moving items between rows when draggableItems is enabled. Enable for resource bookings; leave off for feature-based Gantt rows.' } },
|
|
22
|
+
resizableItems: { type: Boolean, default: false, _edit: { description: 'Show a right-edge grip for shortening or extending an item. Emits item-resize.' } },
|
|
23
|
+
workingHours: { type: Array, default: () => [6, 18], _edit: { description: 'Local start and exclusive end hour, e.g. [6, 18]. Outside hours are muted in day zoom.' } },
|
|
24
|
+
weekendDays: { type: Array, default: () => [0, 6], _edit: { description: 'Non-working weekdays: Sunday = 0, Saturday = 6. Muted, still selectable.' } },
|
|
25
|
+
hideNonWorkingHours: { type: Boolean, default: false, _edit: { description: 'Compress out-of-hours time in day zoom. The toolbar can expand it again.' } },
|
|
26
|
+
hideWeekends: { type: Boolean, default: false, _edit: { description: 'Compress configured non-working weekdays at every zoom.' } },
|
|
27
|
+
selectionStep: { type: Number, default: 30, _edit: { description: 'Hourly-view selection snap in minutes (1–60). Other views snap to visible calendar days.' } },
|
|
28
|
+
rowHeight: { type: Number, default: 64, _edit: { description: 'Minimum row height in pixels. Overlapping items expand the row.' } },
|
|
29
|
+
viewportHeight: { type: Number, default: 460 },
|
|
30
|
+
rowHeading: { type: String, default: 'People / work' },
|
|
31
|
+
addRowLabel: { type: String, default: 'Add row' },
|
|
32
|
+
showToolbar: { type: Boolean, default: true },
|
|
33
|
+
showCurrentTime: { type: Boolean, default: true },
|
|
34
|
+
loading: { type: Boolean, default: false },
|
|
35
|
+
ariaLabel: { type: String, default: 'Planning timeline' },
|
|
36
|
+
};
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<script setup>
|
|
40
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue';
|
|
41
|
+
import DomButton from '../../components/button/DomButton.vue';
|
|
42
|
+
import DomTextInput from '../../forms/text-input/DomTextInput.vue';
|
|
43
|
+
import { calculateVirtualWindow } from '../_shared/calculateVirtualWindow.js';
|
|
44
|
+
import { createGanttAxis, ganttDateToX, ganttXToDate, ganttSnapWidth, layoutGanttRows, moveGanttSpan, parseGanttDate, resizeGanttSpan } from './ganttTime.js';
|
|
45
|
+
|
|
46
|
+
defineOptions({
|
|
47
|
+
__doc: {
|
|
48
|
+
name: 'Gantt',
|
|
49
|
+
tag: '<DomGantt>',
|
|
50
|
+
description: 'One horizontal timeline for team resources, project schedules, and roadmaps, with time zoom, working hours, and drag-to-plan ranges.',
|
|
51
|
+
icon: 'M4 4v16h16M7 7h6M10 11h8M8 15h7',
|
|
52
|
+
events: [
|
|
53
|
+
{ name: 'time-range-select', payload: 'GanttRangePayload', description: 'A requested span with source row, rowKey, Date start/end, elapsed durationMinutes, and original event. End is exclusive; the application creates the item.' },
|
|
54
|
+
{ name: 'row-create', payload: '{ name: string }', description: 'Validated row name. The application assigns an id and adds its row.' },
|
|
55
|
+
{ name: 'item-click', payload: 'GanttItemPayload', description: 'Source item and row, resolved dates, and original event for an application-owned editor.' },
|
|
56
|
+
{ name: 'item-move', payload: 'GanttItemChangePayload', description: 'Proposed dates and source/target rows after a drag or keyboard move. Source records remain unchanged until the application accepts the event.' },
|
|
57
|
+
{ name: 'item-resize', payload: 'GanttItemChangePayload', description: 'Proposed exclusive end after right-edge resizing. The start and row are preserved.' },
|
|
58
|
+
{ name: 'update:zoom', payload: "'months' | 'weeks' | 'days'", description: 'Toolbar zoom changed, preserving the date at the left of the viewport.' },
|
|
59
|
+
{ name: 'update:planMode', payload: 'boolean', description: 'Plan mode changed.' },
|
|
60
|
+
{ name: 'update:hideNonWorkingHours', payload: 'boolean', description: 'Out-of-hours visibility changed.' },
|
|
61
|
+
{ name: 'update:hideWeekends', payload: 'boolean', description: 'Non-working day visibility changed.' },
|
|
62
|
+
{ name: 'visible-range-change', payload: '{ startDate: Date, endDate: Date }', description: 'Visible date window after scrolling or zooming, for application-owned data loading.' },
|
|
63
|
+
],
|
|
64
|
+
slots: [
|
|
65
|
+
{ name: 'row', payload: '{ row, rowKey, label }', description: 'Replace a row label, for example with a member avatar and role.' },
|
|
66
|
+
{ name: 'item', payload: '{ item, row, startDate, endDate }', description: 'Replace bar content inside its focusable item button. Avoid nested interactive controls.' },
|
|
67
|
+
{ name: 'toolbar', description: 'Append application filters or actions.' },
|
|
68
|
+
{ name: 'empty', description: 'Replace the empty collection message.' },
|
|
69
|
+
],
|
|
70
|
+
keyboard: [
|
|
71
|
+
{ keys: '← / →', action: 'Move the focused row’s time cursor one selection step.' },
|
|
72
|
+
{ keys: 'Shift + ← / →', action: 'Extend a time selection.' },
|
|
73
|
+
{ keys: 'Enter', action: 'Request the selected time span.' },
|
|
74
|
+
{ keys: 'Escape', action: 'Cancel a pointer or keyboard selection.' },
|
|
75
|
+
{ keys: 'Alt + ← / →', action: 'Move a focused draggable item one time increment.' },
|
|
76
|
+
{ keys: 'Alt + ↑ / ↓', action: 'Move a focused item to the previous/next resource row when row changes are enabled.' },
|
|
77
|
+
{ keys: '← / → on resize grip', action: 'Shorten or extend an item by one time increment.' },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const props = defineProps(ganttProps);
|
|
83
|
+
const emit = defineEmits(['time-range-select', 'row-create', 'item-click', 'item-move', 'item-resize', 'update:zoom', 'update:planMode', 'update:hideNonWorkingHours', 'update:hideWeekends', 'visible-range-change']);
|
|
84
|
+
const viewport = ref(null);
|
|
85
|
+
const localZoom = ref(props.zoom);
|
|
86
|
+
const planning = ref(props.planMode);
|
|
87
|
+
const hideHours = ref(props.hideNonWorkingHours);
|
|
88
|
+
const hideDays = ref(props.hideWeekends);
|
|
89
|
+
const rowName = ref('');
|
|
90
|
+
const scrollLeft = ref(0);
|
|
91
|
+
const viewportWidth = ref(900);
|
|
92
|
+
const labelWidth = ref(200);
|
|
93
|
+
const now = ref(null);
|
|
94
|
+
const selection = ref(null);
|
|
95
|
+
const keyboardSelection = ref(null);
|
|
96
|
+
const itemInteraction = ref(null);
|
|
97
|
+
const helpId = useId();
|
|
98
|
+
let resizeObserver;
|
|
99
|
+
let clockTimer;
|
|
100
|
+
let scrollFrame;
|
|
101
|
+
let pointerX = 0;
|
|
102
|
+
let pointerY = 0;
|
|
103
|
+
let activePointerId = null;
|
|
104
|
+
let suppressedClick = null;
|
|
105
|
+
|
|
106
|
+
const axis = computed(buildAxis);
|
|
107
|
+
const lanes = computed(buildLanes);
|
|
108
|
+
const renderWindow = computed(getRenderWindow);
|
|
109
|
+
const visibleCells = computed(getVisibleCells);
|
|
110
|
+
const headerGroups = computed(getHeaderGroups);
|
|
111
|
+
const currentX = computed(getCurrentX);
|
|
112
|
+
const rangeLabel = computed(getRangeLabel);
|
|
113
|
+
const preview = computed(getPreview);
|
|
114
|
+
const itemPreview = computed(getItemPreview);
|
|
115
|
+
|
|
116
|
+
watch(() => props.zoom, syncZoom);
|
|
117
|
+
watch(() => props.planMode, syncPlanning);
|
|
118
|
+
watch(() => props.hideNonWorkingHours, syncHours);
|
|
119
|
+
watch(() => props.hideWeekends, syncDays);
|
|
120
|
+
watch(axis, preserveDate, { flush: 'pre' });
|
|
121
|
+
watch(() => [props.draggableItems, props.resizableItems, props.allowRowChange, props.loading], cancelSelection);
|
|
122
|
+
onMounted(mountTimeline);
|
|
123
|
+
onBeforeUnmount(unmountTimeline);
|
|
124
|
+
defineExpose({ scrollToDate, scrollToToday });
|
|
125
|
+
|
|
126
|
+
/** Builds the horizontal axis from current display settings. @returns {object} Axis. */
|
|
127
|
+
function buildAxis() {
|
|
128
|
+
return createGanttAxis({ startDate: props.startDate, days: props.days, workingHours: props.workingHours, weekendDays: props.weekendDays, zoom: localZoom.value, hideNonWorkingHours: hideHours.value, hideWeekends: hideDays.value });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Resolves source records using shared data-component field semantics. @returns {Array<object>} Lanes. */
|
|
132
|
+
function buildLanes() {
|
|
133
|
+
return layoutGanttRows(props.rows || [], props.items || [], props, axis.value);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Calculates the horizontally mounted cell window using the shared virtualizer. @returns {object} Window. */
|
|
137
|
+
function getRenderWindow() {
|
|
138
|
+
return calculateVirtualWindow({ itemCount: axis.value.cells.length, itemSize: axis.value.cellWidth, scrollOffset: scrollLeft.value, viewportSize: viewportWidth.value - labelWidth.value, overscan: 3 });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Lists the cell decorations needed around the viewport. @returns {Array<object>} Visible cells. */
|
|
142
|
+
function getVisibleCells() {
|
|
143
|
+
return axis.value.cells.slice(renderWindow.value.startIndex, renderWindow.value.endIndex).map((cell) => ({
|
|
144
|
+
...cell,
|
|
145
|
+
boundary: cellBoundary(cell),
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Classifies a boundary against the preceding visible cell, including hidden periods.
|
|
151
|
+
* Weeks begin on Monday; month boundaries take precedence when both change.
|
|
152
|
+
* @param {object} cell Axis cell at the right of the boundary.
|
|
153
|
+
* @returns {'hour'|'day'|'week'|'month'} Strongest calendar boundary.
|
|
154
|
+
*/
|
|
155
|
+
function cellBoundary(cell) {
|
|
156
|
+
const previous = axis.value.cells[Math.round(cell.left / axis.value.cellWidth) - 1];
|
|
157
|
+
if (!previous) return 'day';
|
|
158
|
+
const date = new Date(cell.start);
|
|
159
|
+
const before = new Date(previous.start);
|
|
160
|
+
if (date.getFullYear() !== before.getFullYear() || date.getMonth() !== before.getMonth()) return 'month';
|
|
161
|
+
if (date.toDateString() === before.toDateString()) return 'hour';
|
|
162
|
+
date.setHours(0, 0, 0, 0);
|
|
163
|
+
before.setHours(0, 0, 0, 0);
|
|
164
|
+
date.setDate(date.getDate() - (date.getDay() + 6) % 7);
|
|
165
|
+
before.setDate(before.getDate() - (before.getDay() + 6) % 7);
|
|
166
|
+
return +date !== +before ? 'week' : 'day';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Scales boundary emphasis with zoom while keeping header and row lines aligned.
|
|
171
|
+
* Day zoom retains its hourly hierarchy; wider views use faint daily divisions.
|
|
172
|
+
* Month zoom uses a subtle step above its weekly boundaries.
|
|
173
|
+
* @param {object} cell Visible cell with a resolved calendar boundary.
|
|
174
|
+
* @returns {string} Tailwind utilities for its leading vertical border.
|
|
175
|
+
*/
|
|
176
|
+
function boundaryClass(cell) {
|
|
177
|
+
const dayView = {
|
|
178
|
+
hour: 'border-l border-l-border/60',
|
|
179
|
+
day: 'border-l border-l-canvas-fg/20',
|
|
180
|
+
week: 'border-l border-l-canvas-fg/35',
|
|
181
|
+
month: 'border-l-2 border-l-canvas-fg/50',
|
|
182
|
+
};
|
|
183
|
+
if (localZoom.value === 'days') return dayView[cell.boundary];
|
|
184
|
+
return {
|
|
185
|
+
hour: dayView.hour,
|
|
186
|
+
day: dayView.hour,
|
|
187
|
+
week: dayView.day,
|
|
188
|
+
month: localZoom.value === 'months' ? 'border-l border-l-canvas-fg/25' : dayView.week,
|
|
189
|
+
}[cell.boundary];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Groups visible header cells by month or local day at hourly zoom. @returns {Array<object>} Labels and geometry. */
|
|
193
|
+
function getHeaderGroups() {
|
|
194
|
+
const groups = [];
|
|
195
|
+
for (const cell of visibleCells.value) {
|
|
196
|
+
const date = new Date(cell.start);
|
|
197
|
+
const key = localZoom.value === 'days' ? date.toDateString() : `${date.getFullYear()}-${date.getMonth()}`;
|
|
198
|
+
let group = groups[groups.length - 1];
|
|
199
|
+
if (!group || group.key !== key) {
|
|
200
|
+
group = { key, left: cell.left, width: 0, label: date.toLocaleDateString('en-GB', localZoom.value === 'days' ? { weekday: 'short', day: 'numeric', month: 'short' } : { month: 'long', year: 'numeric' }) };
|
|
201
|
+
groups.push(group);
|
|
202
|
+
}
|
|
203
|
+
group.width += cell.width;
|
|
204
|
+
}
|
|
205
|
+
return groups;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Locates live time only within a visible time cell, avoiding false markers in omitted hours. @returns {number|null} Position. */
|
|
209
|
+
function getCurrentX() {
|
|
210
|
+
if (!props.showCurrentTime || now.value === null) return null;
|
|
211
|
+
const position = ganttDateToX(axis.value, now.value);
|
|
212
|
+
const cell = axis.value.cells[Math.floor(position / axis.value.cellWidth)];
|
|
213
|
+
return cell && now.value >= cell.start && now.value < cell.end ? position : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Formats the current viewport's first and last calendar dates. @returns {string} Toolbar range. */
|
|
217
|
+
function getRangeLabel() {
|
|
218
|
+
const start = ganttXToDate(axis.value, scrollLeft.value);
|
|
219
|
+
const end = ganttXToDate(axis.value, scrollLeft.value + Math.max(0, viewportWidth.value - labelWidth.value), 'end');
|
|
220
|
+
if (start === null || end === null) return 'No visible dates';
|
|
221
|
+
return `${dateLabel(start)} – ${dateLabel(Math.max(start, end - 1))}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Formats a compact local date. @param {number} time Timestamp. @returns {string} Date label. */
|
|
225
|
+
function dateLabel(time) {
|
|
226
|
+
return new Date(time).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Labels hour cells or day boundaries without crowding month zoom. @param {object} cell Axis cell. @returns {string} Label. */
|
|
230
|
+
function cellLabel(cell) {
|
|
231
|
+
const date = new Date(cell.start);
|
|
232
|
+
if (localZoom.value === 'days') return String(date.getHours()).padStart(2, '0');
|
|
233
|
+
if (localZoom.value === 'months') return date.getDay() === 1 || date.getDate() === 1 ? String(date.getDate()) : '';
|
|
234
|
+
return `${date.toLocaleDateString('en-GB', { weekday: 'narrow' })} ${date.getDate()}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Produces theme-aware item styling. @param {object} record Item presentation. @returns {string} Utility classes. */
|
|
238
|
+
function itemTone(record) {
|
|
239
|
+
return { success: 'border-success/30 bg-success/15 text-canvas-fg', warning: 'border-warning/30 bg-warning/15 text-canvas-fg', destructive: 'border-destructive/30 bg-destructive/15 text-canvas-fg', secondary: 'border-border bg-secondary text-canvas-fg' }[record.tone] || 'border-primary/30 bg-primary/15 text-canvas-fg';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Lists bars intersecting the visible viewport, with an overscan margin. @param {object} lane Row geometry. @returns {Array<object>} Bars. */
|
|
243
|
+
function visibleItems(lane) {
|
|
244
|
+
return lane.records.filter((record) => record.left + record.width >= scrollLeft.value - 100 && record.left <= scrollLeft.value + viewportWidth.value + 100);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Clips long bars to the visible time window so their names remain readable while scrolling.
|
|
249
|
+
* The original interval remains available in the item payload and accessible label.
|
|
250
|
+
* @param {object} record Item geometry.
|
|
251
|
+
* @returns {object} Visible fragment's inline position and size.
|
|
252
|
+
*/
|
|
253
|
+
function itemStyle(record) {
|
|
254
|
+
const left = Math.max(record.left, scrollLeft.value);
|
|
255
|
+
const right = Math.min(record.left + record.width, scrollLeft.value + viewportWidth.value - labelWidth.value);
|
|
256
|
+
const width = right > left ? Math.max(2, right - left - 2) : 0;
|
|
257
|
+
return { left: `${left + 1}px`, width: `${width}px`, top: `${record.top}px`, paddingInline: width < 24 ? '0' : undefined, display: width > 0 ? undefined : 'none' };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Formats the full accessible item interval. @param {object} record Scheduled item. @returns {string} Button label. */
|
|
261
|
+
function itemLabel(record) {
|
|
262
|
+
return `${record.title || 'Untitled item'}: ${new Date(record.start).toLocaleString('en-GB')} to ${new Date(record.end).toLocaleString('en-GB')}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Emits the original record for application-owned editing. @param {object} lane Row. @param {object} record Item. @param {Event} event Input event. @returns {void} */
|
|
266
|
+
function selectItem(lane, record, event) {
|
|
267
|
+
if (suppressedClick?.key === record.key && Date.now() < suppressedClick.until) return;
|
|
268
|
+
emit('item-click', { item: record.item, row: lane.row, rowKey: lane.key, startDate: new Date(record.start), endDate: new Date(record.end), event });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Shows the resize grip only when the actual end is inside the visible window.
|
|
273
|
+
* A clipped bar edge must not masquerade as the item's editable end.
|
|
274
|
+
* @param {object} record Scheduled item geometry.
|
|
275
|
+
* @returns {boolean} Whether the end grip can be shown.
|
|
276
|
+
*/
|
|
277
|
+
function showResizeHandle(record) {
|
|
278
|
+
const right = record.left + record.width;
|
|
279
|
+
return props.resizableItems && record.end <= +axis.value.end && right > scrollLeft.value + 10 && right <= scrollLeft.value + viewportWidth.value - labelWidth.value;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Begins a potential item gesture, retaining click-to-edit until movement exceeds four pixels.
|
|
284
|
+
* Touch scrolling remains native on bar bodies; the explicit end grip supports touch resizing.
|
|
285
|
+
* @param {'move'|'resize'} kind Gesture type.
|
|
286
|
+
* @param {object} lane Original row.
|
|
287
|
+
* @param {object} record Original item geometry.
|
|
288
|
+
* @param {PointerEvent} event Pointer-down event.
|
|
289
|
+
* @returns {void}
|
|
290
|
+
*/
|
|
291
|
+
function startItemInteraction(kind, lane, record, event) {
|
|
292
|
+
if (props.loading || event.button !== 0 || !axis.value.width) return;
|
|
293
|
+
if (kind === 'move' && (!props.draggableItems || event.pointerType === 'touch')) return;
|
|
294
|
+
if (kind === 'resize' && !props.resizableItems) return;
|
|
295
|
+
event.preventDefault();
|
|
296
|
+
cancelSelection();
|
|
297
|
+
activePointerId = event.pointerId;
|
|
298
|
+
pointerX = event.clientX;
|
|
299
|
+
pointerY = event.clientY;
|
|
300
|
+
itemInteraction.value = { kind, lane, record, targetLane: lane, active: false, valid: true, initialX: pointerX, initialY: pointerY, anchorX: pointerPosition(pointerX), proposal: { start: record.start, end: record.end } };
|
|
301
|
+
event.currentTarget.focus({ preventScroll: true });
|
|
302
|
+
window.addEventListener('pointermove', moveItemInteraction, { passive: false });
|
|
303
|
+
window.addEventListener('pointerup', finishItemInteraction);
|
|
304
|
+
window.addEventListener('pointercancel', cancelSelection);
|
|
305
|
+
window.addEventListener('blur', cancelSelection);
|
|
306
|
+
window.addEventListener('keydown', cancelOnEscape);
|
|
307
|
+
scrollFrame = requestAnimationFrame(autoScroll);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Finds a real row beneath the pointer, excluding sticky headers and off-chart drops.
|
|
312
|
+
* @returns {object|null} Target row presentation, or null outside the time area.
|
|
313
|
+
*/
|
|
314
|
+
function pointerLane() {
|
|
315
|
+
const rect = viewport.value.getBoundingClientRect();
|
|
316
|
+
if (pointerX < rect.left + labelWidth.value || pointerX > rect.right || pointerY < rect.top + 64 || pointerY > rect.bottom) return null;
|
|
317
|
+
const element = document.elementFromPoint(pointerX, pointerY)?.closest('[data-gantt-row-index]');
|
|
318
|
+
return element && viewport.value.contains(element) ? lanes.value[Number(element.dataset.ganttRowIndex)] || null : null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Recalculates the snapped proposal and legal target after pointer or viewport movement.
|
|
323
|
+
* @returns {void}
|
|
324
|
+
*/
|
|
325
|
+
function updateItemProposal() {
|
|
326
|
+
const state = itemInteraction.value;
|
|
327
|
+
if (!state) return;
|
|
328
|
+
const deltaX = pointerPosition(pointerX) - state.anchorX;
|
|
329
|
+
const over = pointerLane();
|
|
330
|
+
const targetLane = state.kind === 'move' && props.allowRowChange ? over : state.lane;
|
|
331
|
+
const proposal = state.kind === 'resize'
|
|
332
|
+
? resizeGanttSpan(axis.value, state.record, deltaX, localZoom.value, props.selectionStep)
|
|
333
|
+
: moveGanttSpan(axis.value, state.record, state.anchorX, deltaX, localZoom.value, props.selectionStep);
|
|
334
|
+
itemInteraction.value = { ...state, targetLane, valid: Boolean(over && targetLane), proposal };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Activates and updates an item gesture without changing the app-owned record.
|
|
339
|
+
* @param {PointerEvent} event Pointer-move event.
|
|
340
|
+
* @returns {void}
|
|
341
|
+
*/
|
|
342
|
+
function moveItemInteraction(event) {
|
|
343
|
+
const state = itemInteraction.value;
|
|
344
|
+
if (!state || event.pointerId !== activePointerId) return;
|
|
345
|
+
event.preventDefault();
|
|
346
|
+
pointerX = event.clientX;
|
|
347
|
+
pointerY = event.clientY;
|
|
348
|
+
const distance = Math.max(Math.abs(pointerX - state.initialX), state.kind === 'move' && props.allowRowChange ? Math.abs(pointerY - state.initialY) : 0);
|
|
349
|
+
if (!state.active && distance < 4) return;
|
|
350
|
+
itemInteraction.value = { ...state, active: true };
|
|
351
|
+
updateItemProposal();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Builds a foreground preview on the proposed row while preserving existing bookings.
|
|
356
|
+
* @returns {object|null} Transient item geometry with its target lane.
|
|
357
|
+
*/
|
|
358
|
+
function getItemPreview() {
|
|
359
|
+
const state = itemInteraction.value;
|
|
360
|
+
if (!state?.active || !state.valid) return null;
|
|
361
|
+
const left = ganttDateToX(axis.value, state.proposal.start);
|
|
362
|
+
const right = ganttDateToX(axis.value, state.proposal.end);
|
|
363
|
+
return { ...state.record, ...state.proposal, left, width: right - left, top: state.targetLane.key === state.lane.key ? state.record.top : 12, lane: state.targetLane };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Publishes a changed interval only while the original item and both rows still exist.
|
|
368
|
+
* Concurrent source changes cancel a stale gesture instead of overwriting newer dates.
|
|
369
|
+
* @param {object} state Completed gesture or keyboard proposal.
|
|
370
|
+
* @param {Event} event Originating input event.
|
|
371
|
+
* @returns {void}
|
|
372
|
+
*/
|
|
373
|
+
function commitItemChange(state, event) {
|
|
374
|
+
if (!state.valid || props.loading) return;
|
|
375
|
+
if (state.kind === 'move' && !props.draggableItems) return;
|
|
376
|
+
if (state.kind === 'resize' && !props.resizableItems) return;
|
|
377
|
+
const source = lanes.value.find((lane) => lane.key === state.lane.key);
|
|
378
|
+
const current = source?.records.find((record) => record.key === state.record.key);
|
|
379
|
+
const target = lanes.value.find((lane) => lane.key === state.targetLane?.key);
|
|
380
|
+
if (!current || !target || current.start !== state.record.start || current.end !== state.record.end) return;
|
|
381
|
+
if (!props.allowRowChange && target.key !== source.key) return;
|
|
382
|
+
const { start, end } = state.proposal;
|
|
383
|
+
if (end <= start || (start === current.start && end === current.end && target.key === source.key)) return;
|
|
384
|
+
emit(state.kind === 'move' ? 'item-move' : 'item-resize', {
|
|
385
|
+
item: current.item,
|
|
386
|
+
sourceRow: source.row,
|
|
387
|
+
targetRow: target.row,
|
|
388
|
+
sourceRowKey: source.key,
|
|
389
|
+
targetRowKey: target.key,
|
|
390
|
+
previousStartDate: new Date(current.start),
|
|
391
|
+
previousEndDate: new Date(current.end),
|
|
392
|
+
startDate: new Date(start),
|
|
393
|
+
endDate: new Date(end),
|
|
394
|
+
durationMinutes: (end - start) / 60000,
|
|
395
|
+
event,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Commits a valid drop and suppresses the synthetic click following a completed drag.
|
|
401
|
+
* @param {PointerEvent} event Pointer-up event.
|
|
402
|
+
* @returns {void}
|
|
403
|
+
*/
|
|
404
|
+
function finishItemInteraction(event) {
|
|
405
|
+
if (!itemInteraction.value || event.pointerId !== activePointerId) return;
|
|
406
|
+
moveItemInteraction(event);
|
|
407
|
+
const state = itemInteraction.value;
|
|
408
|
+
if (state.active) commitItemChange(state, event);
|
|
409
|
+
cancelSelection();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Moves focused items with Alt+arrows, preserving ordinary Enter/click editing.
|
|
414
|
+
* @param {object} lane Source row.
|
|
415
|
+
* @param {object} record Scheduled item.
|
|
416
|
+
* @param {KeyboardEvent} event Key event.
|
|
417
|
+
* @returns {void}
|
|
418
|
+
*/
|
|
419
|
+
function onItemKey(lane, record, event) {
|
|
420
|
+
if (!props.draggableItems || props.loading || !event.altKey || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
|
|
421
|
+
event.preventDefault();
|
|
422
|
+
event.stopPropagation();
|
|
423
|
+
cancelSelection();
|
|
424
|
+
let targetLane = lane;
|
|
425
|
+
let proposal = { start: record.start, end: record.end };
|
|
426
|
+
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
|
427
|
+
if (!props.allowRowChange) return;
|
|
428
|
+
const index = lanes.value.findIndex((candidate) => candidate.key === lane.key);
|
|
429
|
+
targetLane = lanes.value[index + (event.key === 'ArrowDown' ? 1 : -1)];
|
|
430
|
+
} else {
|
|
431
|
+
proposal = moveGanttSpan(axis.value, record, record.left, (event.key === 'ArrowRight' ? 1 : -1) * snapWidth(), localZoom.value, props.selectionStep);
|
|
432
|
+
}
|
|
433
|
+
if (targetLane) {
|
|
434
|
+
commitItemChange({ kind: 'move', lane, record, targetLane, proposal, valid: true }, event);
|
|
435
|
+
focusChangedItem(record.key, targetLane.key, 'move');
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Resizes the focused end grip with arrow keys using the same snapping as the pointer.
|
|
441
|
+
* @param {object} lane Source row.
|
|
442
|
+
* @param {object} record Scheduled item.
|
|
443
|
+
* @param {KeyboardEvent} event Key event.
|
|
444
|
+
* @returns {void}
|
|
445
|
+
*/
|
|
446
|
+
function onResizeKey(lane, record, event) {
|
|
447
|
+
if (!props.resizableItems || props.loading || !['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
|
|
448
|
+
event.preventDefault();
|
|
449
|
+
event.stopPropagation();
|
|
450
|
+
cancelSelection();
|
|
451
|
+
const proposal = resizeGanttSpan(axis.value, record, (event.key === 'ArrowRight' ? 1 : -1) * snapWidth(), localZoom.value, props.selectionStep);
|
|
452
|
+
commitItemChange({ kind: 'resize', lane, record, targetLane: lane, proposal, valid: true }, event);
|
|
453
|
+
focusChangedItem(record.key, lane.key, 'resize');
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Restores keyboard focus after an application accepts a move into another row.
|
|
458
|
+
* @param {string|number} itemKey Stable item id.
|
|
459
|
+
* @param {string|number} rowKey Target row id.
|
|
460
|
+
* @param {'move'|'resize'} kind Control to focus.
|
|
461
|
+
* @returns {Promise<void>} Resolves after the accepted data change renders.
|
|
462
|
+
*/
|
|
463
|
+
async function focusChangedItem(itemKey, rowKey, kind) {
|
|
464
|
+
await nextTick();
|
|
465
|
+
if (!viewport.value) return;
|
|
466
|
+
const laneIndex = lanes.value.findIndex((lane) => lane.key === rowKey);
|
|
467
|
+
const record = lanes.value[laneIndex]?.records.find((candidate) => candidate.key === itemKey);
|
|
468
|
+
if (!record) return;
|
|
469
|
+
const available = viewportWidth.value - labelWidth.value;
|
|
470
|
+
const position = kind === 'resize' ? record.left + record.width : Math.max(record.left, Math.min(scrollLeft.value + 12, record.left + record.width - 2));
|
|
471
|
+
if (position < scrollLeft.value) viewport.value.scrollLeft = Math.max(0, position - 12);
|
|
472
|
+
else if (position > scrollLeft.value + available - 12) viewport.value.scrollLeft = position - available + 12;
|
|
473
|
+
onScroll();
|
|
474
|
+
await nextTick();
|
|
475
|
+
const row = viewport.value.querySelectorAll('[data-gantt-row-index]')[laneIndex];
|
|
476
|
+
if (row) {
|
|
477
|
+
const bounds = viewport.value.getBoundingClientRect();
|
|
478
|
+
const rowBounds = row.getBoundingClientRect();
|
|
479
|
+
if (rowBounds.top < bounds.top + 64) viewport.value.scrollTop += rowBounds.top - bounds.top - 64;
|
|
480
|
+
else if (rowBounds.bottom > bounds.bottom) viewport.value.scrollTop += rowBounds.bottom - bounds.bottom;
|
|
481
|
+
}
|
|
482
|
+
const selector = kind === 'resize' ? '[data-gantt-resize]' : '[data-gantt-item]';
|
|
483
|
+
const button = Array.from(row?.querySelectorAll(selector) || []).find((element) => element.dataset.ganttItemKey === String(itemKey));
|
|
484
|
+
button?.focus({ preventScroll: true });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Syncs an externally controlled zoom. @param {string} value Requested zoom. @returns {void} */
|
|
488
|
+
function syncZoom(value) { localZoom.value = value; }
|
|
489
|
+
/** Syncs external planning state and abandons in-progress selections. @param {boolean} value State. @returns {void} */
|
|
490
|
+
function syncPlanning(value) { planning.value = value; cancelSelection(); }
|
|
491
|
+
/** Syncs externally controlled hidden hours. @param {boolean} value State. @returns {void} */
|
|
492
|
+
function syncHours(value) { hideHours.value = value; }
|
|
493
|
+
/** Syncs externally controlled hidden weekdays. @param {boolean} value State. @returns {void} */
|
|
494
|
+
function syncDays(value) { hideDays.value = value; }
|
|
495
|
+
|
|
496
|
+
/** Updates a toolbar setting and reports its v-model value. @param {string} name Setting. @param {unknown} value Next value. @returns {void} */
|
|
497
|
+
function updateSetting(name, value) {
|
|
498
|
+
cancelSelection();
|
|
499
|
+
if (name === 'zoom') localZoom.value = value;
|
|
500
|
+
if (name === 'planMode') planning.value = value;
|
|
501
|
+
if (name === 'hideNonWorkingHours') hideHours.value = value;
|
|
502
|
+
if (name === 'hideWeekends') hideDays.value = value;
|
|
503
|
+
emit(`update:${name}`, value);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Preserves the left-hand date across zoom, range, and working-time changes. @param {object} next Axis. @param {object} previous Previous axis. @returns {Promise<void>} Settles after layout. */
|
|
507
|
+
async function preserveDate(next, previous) {
|
|
508
|
+
cancelSelection();
|
|
509
|
+
const date = ganttXToDate(previous, scrollLeft.value);
|
|
510
|
+
await nextTick();
|
|
511
|
+
if (viewport.value) viewport.value.scrollLeft = date === null ? 0 : ganttDateToX(next, date);
|
|
512
|
+
measureViewport();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Measures responsive label width and reports visible dates. @returns {void} */
|
|
516
|
+
function measureViewport() {
|
|
517
|
+
if (!viewport.value) return;
|
|
518
|
+
viewportWidth.value = viewport.value.clientWidth;
|
|
519
|
+
labelWidth.value = viewport.value.querySelector('[data-gantt-corner]')?.getBoundingClientRect().width || 200;
|
|
520
|
+
onScroll();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Tracks native scroll and emits the visible application query window. @returns {void} */
|
|
524
|
+
function onScroll() {
|
|
525
|
+
if (!viewport.value) return;
|
|
526
|
+
scrollLeft.value = viewport.value.scrollLeft;
|
|
527
|
+
const start = ganttXToDate(axis.value, scrollLeft.value);
|
|
528
|
+
const end = ganttXToDate(axis.value, scrollLeft.value + viewportWidth.value - labelWidth.value, 'end');
|
|
529
|
+
if (start !== null && end !== null) emit('visible-range-change', { startDate: new Date(start), endDate: new Date(end) });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Scrolls within the supplied date window. Hidden instants resolve to the next visible boundary. @param {Date|string|number} value Target. @returns {void} */
|
|
533
|
+
function scrollToDate(value) {
|
|
534
|
+
const date = parseGanttDate(value);
|
|
535
|
+
if (viewport.value && date) viewport.value.scrollLeft = ganttDateToX(axis.value, date);
|
|
536
|
+
onScroll();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Returns to the live day within the supplied scrollable window. @returns {void} */
|
|
540
|
+
function scrollToToday() { scrollToDate(new Date()); }
|
|
541
|
+
|
|
542
|
+
/** Scrolls one viewport in either direction. @param {number} direction -1 or 1. @returns {void} */
|
|
543
|
+
function pageTime(direction) {
|
|
544
|
+
if (viewport.value) viewport.value.scrollLeft += direction * Math.max(axis.value.cellWidth, viewportWidth.value - labelWidth.value);
|
|
545
|
+
onScroll();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Updates live time after mount, avoiding server/client hydration differences. @returns {void} */
|
|
549
|
+
function tick() { now.value = Date.now(); }
|
|
550
|
+
|
|
551
|
+
/** Starts measurement and the minute clock. @returns {void} */
|
|
552
|
+
function mountTimeline() {
|
|
553
|
+
tick();
|
|
554
|
+
clockTimer = window.setInterval(tick, 30000);
|
|
555
|
+
resizeObserver = new ResizeObserver(measureViewport);
|
|
556
|
+
resizeObserver.observe(viewport.value);
|
|
557
|
+
measureViewport();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Releases timers, observers, and pointer listeners. @returns {void} */
|
|
561
|
+
function unmountTimeline() {
|
|
562
|
+
cancelSelection();
|
|
563
|
+
resizeObserver?.disconnect();
|
|
564
|
+
window.clearInterval(clockTimer);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Computes the pixel snap step; day-level views use whole visible dates. @returns {number} Pixels. */
|
|
568
|
+
function snapWidth() {
|
|
569
|
+
return ganttSnapWidth(axis.value, localZoom.value, props.selectionStep);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Finds a bounded snapped position inside the time axis. @param {number} x Unsnapped pixels. @returns {number} Snap start. */
|
|
573
|
+
function snapX(x) {
|
|
574
|
+
const step = snapWidth();
|
|
575
|
+
return Math.max(0, Math.min(axis.value.width - step, Math.floor((x + 0.0001) / step) * step));
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Gets axis-relative pointer position through the sticky row header. @param {number} clientX Viewport coordinate. @returns {number} Axis pixels. */
|
|
579
|
+
function pointerPosition(clientX) {
|
|
580
|
+
return clientX - viewport.value.getBoundingClientRect().left - labelWidth.value + viewport.value.scrollLeft;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Starts mouse/pen selection while retaining native touch scrolling. @param {object} lane Source row. @param {PointerEvent} event Pointer event. @returns {void} */
|
|
584
|
+
function startSelection(lane, event) {
|
|
585
|
+
if (!planning.value || props.loading || !axis.value.width || event.button !== 0 || event.pointerType === 'touch') return;
|
|
586
|
+
if (event.target.closest('button, input, a, select, textarea, [data-gantt-ignore-selection]')) return;
|
|
587
|
+
event.preventDefault();
|
|
588
|
+
cancelSelection();
|
|
589
|
+
activePointerId = event.pointerId;
|
|
590
|
+
pointerX = event.clientX;
|
|
591
|
+
const anchor = snapX(pointerPosition(pointerX));
|
|
592
|
+
selection.value = { lane, anchor, cursor: anchor };
|
|
593
|
+
event.currentTarget.focus({ preventScroll: true });
|
|
594
|
+
window.addEventListener('pointermove', moveSelection, { passive: false });
|
|
595
|
+
window.addEventListener('pointerup', finishSelection);
|
|
596
|
+
window.addEventListener('pointercancel', cancelSelection);
|
|
597
|
+
window.addEventListener('blur', cancelSelection);
|
|
598
|
+
window.addEventListener('keydown', cancelOnEscape);
|
|
599
|
+
scrollFrame = requestAnimationFrame(autoScroll);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Updates the active drag, including reversed selections. @param {PointerEvent} event Pointer event. @returns {void} */
|
|
603
|
+
function moveSelection(event) {
|
|
604
|
+
if (!selection.value || event.pointerId !== activePointerId) return;
|
|
605
|
+
event.preventDefault();
|
|
606
|
+
pointerX = event.clientX;
|
|
607
|
+
selection.value = { ...selection.value, cursor: snapX(pointerPosition(pointerX)) };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Keeps dragging usable across horizontal viewport edges. @returns {void} */
|
|
611
|
+
function autoScroll() {
|
|
612
|
+
if ((!selection.value && !itemInteraction.value) || !viewport.value) return;
|
|
613
|
+
if (itemInteraction.value && !itemInteraction.value.active) {
|
|
614
|
+
scrollFrame = requestAnimationFrame(autoScroll);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const rect = viewport.value.getBoundingClientRect();
|
|
618
|
+
const direction = pointerX > rect.right - 28 ? 1 : pointerX < rect.left + labelWidth.value + 28 ? -1 : 0;
|
|
619
|
+
const vertical = itemInteraction.value ? (pointerY > rect.bottom - 24 ? 1 : pointerY < rect.top + 88 ? -1 : 0) : 0;
|
|
620
|
+
if (vertical) viewport.value.scrollTop += vertical * 8;
|
|
621
|
+
if (direction) {
|
|
622
|
+
viewport.value.scrollLeft += direction * 12;
|
|
623
|
+
onScroll();
|
|
624
|
+
if (selection.value) selection.value = { ...selection.value, cursor: snapX(pointerPosition(pointerX)) };
|
|
625
|
+
}
|
|
626
|
+
if (itemInteraction.value?.active && (direction || vertical)) updateItemProposal();
|
|
627
|
+
scrollFrame = requestAnimationFrame(autoScroll);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Emits a completed selection only for its owning pointer. @param {PointerEvent} event Pointer event. @returns {void} */
|
|
631
|
+
function finishSelection(event) {
|
|
632
|
+
if (!selection.value || event.pointerId !== activePointerId) return;
|
|
633
|
+
moveSelection(event);
|
|
634
|
+
emitRange(selection.value, event);
|
|
635
|
+
cancelSelection();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Cancels pointer interaction when Escape is pressed anywhere. @param {KeyboardEvent} event Key event. @returns {void} */
|
|
639
|
+
function cancelOnEscape(event) { if (event.key === 'Escape') cancelSelection(); }
|
|
640
|
+
|
|
641
|
+
/** Clears selection state and removes every temporary global listener. @returns {void} */
|
|
642
|
+
function cancelSelection() {
|
|
643
|
+
if (itemInteraction.value?.active) suppressedClick = { key: itemInteraction.value.record.key, until: Date.now() + 400 };
|
|
644
|
+
itemInteraction.value = null;
|
|
645
|
+
selection.value = null;
|
|
646
|
+
keyboardSelection.value = null;
|
|
647
|
+
activePointerId = null;
|
|
648
|
+
if (typeof window === 'undefined') return;
|
|
649
|
+
cancelAnimationFrame(scrollFrame);
|
|
650
|
+
window.removeEventListener('pointermove', moveSelection);
|
|
651
|
+
window.removeEventListener('pointerup', finishSelection);
|
|
652
|
+
window.removeEventListener('pointermove', moveItemInteraction);
|
|
653
|
+
window.removeEventListener('pointerup', finishItemInteraction);
|
|
654
|
+
window.removeEventListener('pointercancel', cancelSelection);
|
|
655
|
+
window.removeEventListener('blur', cancelSelection);
|
|
656
|
+
window.removeEventListener('keydown', cancelOnEscape);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Converts pointer or keyboard state to an exclusive-end preview. @returns {object|null} Preview. */
|
|
660
|
+
function getPreview() {
|
|
661
|
+
const state = selection.value || keyboardSelection.value;
|
|
662
|
+
if (!state) return null;
|
|
663
|
+
const left = Math.min(state.anchor, state.cursor);
|
|
664
|
+
const right = Math.min(axis.value.width, Math.max(state.anchor, state.cursor) + snapWidth());
|
|
665
|
+
return { ...state, left, width: right - left, start: ganttXToDate(axis.value, left), end: ganttXToDate(axis.value, right, 'end') };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Reports a requested range while leaving item creation to the application. @param {object} state Selection. @param {Event} event Origin. @returns {void} */
|
|
669
|
+
function emitRange(state, event) {
|
|
670
|
+
const left = Math.min(state.anchor, state.cursor);
|
|
671
|
+
const right = Math.min(axis.value.width, Math.max(state.anchor, state.cursor) + snapWidth());
|
|
672
|
+
const start = ganttXToDate(axis.value, left);
|
|
673
|
+
const end = ganttXToDate(axis.value, right, 'end');
|
|
674
|
+
if (start === null || end === null || end <= start) return;
|
|
675
|
+
emit('time-range-select', { row: state.lane.row, rowKey: state.lane.key, startDate: new Date(start), endDate: new Date(end), durationMinutes: (end - start) / 60000, event });
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Opens an application form with a suggested visible span for touch or keyboard users. @param {object} lane Row. @param {Event} event Origin. @returns {void} */
|
|
679
|
+
function requestRange(lane, event) {
|
|
680
|
+
if (!axis.value.width || props.loading) return;
|
|
681
|
+
const anchor = snapX(scrollLeft.value);
|
|
682
|
+
emitRange({ lane, anchor, cursor: anchor }, event);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Handles a roving time cursor, range extension, and explicit keyboard submission. @param {object} lane Row. @param {KeyboardEvent} event Origin. @returns {void} */
|
|
686
|
+
function onLaneKey(lane, event) {
|
|
687
|
+
if (event.target !== event.currentTarget || !planning.value || props.loading || !axis.value.width) return;
|
|
688
|
+
if (!['ArrowLeft', 'ArrowRight', 'Enter', 'Escape'].includes(event.key)) return;
|
|
689
|
+
event.preventDefault();
|
|
690
|
+
if (event.key === 'Escape') return cancelSelection();
|
|
691
|
+
let state = keyboardSelection.value?.lane.key === lane.key ? keyboardSelection.value : { lane, anchor: snapX(scrollLeft.value), cursor: snapX(scrollLeft.value) };
|
|
692
|
+
if (event.key === 'Enter') {
|
|
693
|
+
emitRange(state, event);
|
|
694
|
+
return cancelSelection();
|
|
695
|
+
}
|
|
696
|
+
const cursor = snapX(state.cursor + (event.key === 'ArrowRight' ? snapWidth() : -snapWidth()));
|
|
697
|
+
state = { lane, cursor, anchor: event.shiftKey ? state.anchor : cursor };
|
|
698
|
+
keyboardSelection.value = state;
|
|
699
|
+
const available = viewportWidth.value - labelWidth.value;
|
|
700
|
+
if (cursor < scrollLeft.value) viewport.value.scrollLeft = cursor;
|
|
701
|
+
if (cursor + snapWidth() > scrollLeft.value + available) viewport.value.scrollLeft = cursor + snapWidth() - available;
|
|
702
|
+
onScroll();
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Validates row creation intent and reveals a synchronously accepted row. @returns {Promise<void>} Settles after the row is rendered. */
|
|
706
|
+
async function createRow() {
|
|
707
|
+
const name = rowName.value.trim();
|
|
708
|
+
if (!name || props.loading) return;
|
|
709
|
+
const previousCount = lanes.value.length;
|
|
710
|
+
emit('row-create', { name });
|
|
711
|
+
rowName.value = '';
|
|
712
|
+
await nextTick();
|
|
713
|
+
if (viewport.value && lanes.value.length > previousCount) {
|
|
714
|
+
viewport.value.scrollTop = viewport.value.scrollHeight;
|
|
715
|
+
const rowElements = viewport.value.querySelectorAll('[data-gantt-lane]');
|
|
716
|
+
rowElements[rowElements.length - 1]?.focus({ preventScroll: true });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
</script>
|
|
720
|
+
|
|
721
|
+
<template>
|
|
722
|
+
<section class="w-full min-w-0 overflow-hidden rounded-xl border border-border bg-canvas text-canvas-fg" :aria-label="ariaLabel" :aria-busy="loading" :class="itemInteraction?.active && ['select-none', itemInteraction.kind === 'resize' ? '[&_*]:cursor-ew-resize!' : '[&_*]:cursor-grabbing!']" data-gantt>
|
|
723
|
+
<div v-if="showToolbar" class="flex flex-wrap items-center gap-2 border-b border-border px-3 py-3 sm:px-4">
|
|
724
|
+
<div class="flex items-center gap-1">
|
|
725
|
+
<DomButton size="sm" variant="ghost" aria-label="Previous time window" @click="pageTime(-1)">←</DomButton>
|
|
726
|
+
<DomButton size="sm" variant="secondary" @click="scrollToToday">Today</DomButton>
|
|
727
|
+
<DomButton size="sm" variant="ghost" aria-label="Next time window" @click="pageTime(1)">→</DomButton>
|
|
728
|
+
</div>
|
|
729
|
+
<span class="mr-auto text-xs font-medium tabular-nums text-muted-fg">{{ rangeLabel }}</span>
|
|
730
|
+
<div class="flex rounded-full border border-border p-0.5" role="group" aria-label="Time zoom">
|
|
731
|
+
<DomButton v-for="level in ['months', 'weeks', 'days']" :key="level" size="sm" :variant="localZoom === level ? 'secondary' : 'ghost'" :aria-pressed="localZoom === level" @click="updateSetting('zoom', level)">{{ level[0].toUpperCase() + level.slice(1) }}</DomButton>
|
|
732
|
+
</div>
|
|
733
|
+
<DomButton size="sm" :variant="planning ? 'primary' : 'secondary'" :aria-pressed="planning" @click="updateSetting('planMode', !planning)">Plan mode</DomButton>
|
|
734
|
+
<div class="flex w-full flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted-fg">
|
|
735
|
+
<button type="button" class="rounded py-1 hover:text-canvas-fg focus-visible:outline-2 focus-visible:outline-ring" :aria-pressed="hideDays" @click="updateSetting('hideWeekends', !hideDays)">{{ hideDays ? 'Show weekends' : 'Hide weekends' }}</button>
|
|
736
|
+
<button v-if="localZoom === 'days'" type="button" class="rounded py-1 hover:text-canvas-fg focus-visible:outline-2 focus-visible:outline-ring" :aria-pressed="hideHours" @click="updateSetting('hideNonWorkingHours', !hideHours)">{{ hideHours ? 'Expand all hours' : 'Working hours only' }}</button>
|
|
737
|
+
<span class="flex items-center gap-1.5"><span class="size-2.5 rounded-sm border border-border bg-secondary/60"></span> Non-working time</span>
|
|
738
|
+
<slot name="toolbar" />
|
|
739
|
+
</div>
|
|
740
|
+
</div>
|
|
741
|
+
<p v-if="planning" :id="helpId" class="border-b border-border bg-primary/5 px-4 py-2 text-xs leading-5 text-muted-fg">Drag across a row to plan. Use + to enter dates. Keyboard: arrows to move, Shift + arrows to extend, Enter to add, Escape to cancel.</p>
|
|
742
|
+
<div ref="viewport" class="relative isolate overflow-auto overscroll-x-contain [--gantt-label-width:136px] sm:[--gantt-label-width:200px]" :style="{ maxHeight: `${Math.max(180, viewportHeight)}px` }" @scroll.passive="onScroll" data-gantt-viewport>
|
|
743
|
+
<div class="relative min-w-full" :style="{ width: `calc(var(--gantt-label-width) + ${axis.width}px)` }">
|
|
744
|
+
<div class="sticky top-0 z-40 flex h-16 border-b border-border bg-canvas" data-gantt-header>
|
|
745
|
+
<div data-gantt-corner class="sticky left-0 z-50 flex w-[var(--gantt-label-width)] shrink-0 items-center border-r border-border bg-canvas px-3 text-xs font-semibold">{{ rowHeading }}</div>
|
|
746
|
+
<div class="relative flex-1 overflow-hidden" aria-hidden="true">
|
|
747
|
+
<div v-for="group in headerGroups" :key="group.key" class="absolute top-0 h-8 overflow-hidden text-xs font-semibold whitespace-nowrap" :style="{ left: `${Math.max(group.left, scrollLeft)}px`, width: `${Math.max(0, group.width - Math.max(0, scrollLeft - group.left))}px` }"><span class="block px-2 pt-2">{{ group.label }}</span></div>
|
|
748
|
+
<div v-for="cell in visibleCells" :key="cell.start" class="absolute bottom-0 flex h-8 items-center justify-center border-t border-t-border/60 text-[10px] tabular-nums text-muted-fg" :class="[boundaryClass(cell), cell.muted && 'bg-secondary/45']" :style="{ left: `${cell.left}px`, width: `${cell.width}px` }">{{ cellLabel(cell) }}</div>
|
|
749
|
+
<div v-for="cell in visibleCells.filter((entry) => entry.boundary === 'month' || (localZoom === 'days' && entry.boundary !== 'hour'))" :key="`boundary-${cell.start}`" class="pointer-events-none absolute top-0 h-8" :class="boundaryClass(cell)" :style="{ left: `${cell.left}px` }" />
|
|
750
|
+
</div>
|
|
751
|
+
</div>
|
|
752
|
+
<div class="relative">
|
|
753
|
+
<div class="pointer-events-none absolute inset-y-0 left-[var(--gantt-label-width)] right-0" aria-hidden="true">
|
|
754
|
+
<div v-for="cell in visibleCells" :key="cell.start" class="absolute inset-y-0" :class="[boundaryClass(cell), cell.muted && 'bg-secondary/40']" :style="{ left: `${cell.left}px`, width: `${cell.width}px` }" :data-gantt-boundary="cell.boundary" />
|
|
755
|
+
</div>
|
|
756
|
+
<div v-for="(lane, laneIndex) in lanes" :key="lane.key" class="relative flex border-b border-border/70 last:border-b-0" :style="{ height: `${lane.height}px` }" :data-gantt-row-index="laneIndex" data-gantt-row>
|
|
757
|
+
<div class="sticky left-0 z-30 flex w-[var(--gantt-label-width)] shrink-0 items-center gap-2 border-r border-border bg-canvas px-3">
|
|
758
|
+
<div class="min-w-0 flex-1 text-xs font-medium"><slot name="row" :row="lane.row" :row-key="lane.key" :label="lane.label"><span class="block truncate" :title="String(lane.label || '')">{{ lane.label }}</span></slot></div>
|
|
759
|
+
<button v-if="planning" type="button" class="grid size-7 shrink-0 place-items-center rounded-md text-lg text-muted-fg hover:bg-secondary hover:text-canvas-fg focus-visible:outline-2 focus-visible:outline-ring disabled:opacity-40" :disabled="loading || !axis.width" :aria-label="`Plan work for ${lane.label}`" @click="requestRange(lane, $event)">+</button>
|
|
760
|
+
</div>
|
|
761
|
+
<div class="relative flex-1 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50" :class="[planning && 'cursor-crosshair', itemPreview?.lane.key === lane.key && 'bg-primary/5 ring-1 ring-inset ring-primary/30']" :tabindex="planning && !loading ? 0 : undefined" :role="planning ? 'group' : undefined" :aria-label="`${lane.label} timeline`" :aria-describedby="planning ? helpId : undefined" @pointerdown="startSelection(lane, $event)" @keydown="onLaneKey(lane, $event)" @blur="keyboardSelection = null" data-gantt-lane>
|
|
762
|
+
<div v-for="record in visibleItems(lane)" :key="record.key" class="group absolute z-10 h-8 rounded-md border shadow-xs" :class="[itemTone(record), itemInteraction?.active && itemInteraction.record.key === record.key && 'opacity-35']" :style="itemStyle(record)">
|
|
763
|
+
<button type="button" class="flex size-full min-w-0 items-center overflow-hidden rounded-md px-2.5 text-left text-xs font-medium transition-[filter] hover:brightness-95 focus-visible:outline-2 focus-visible:outline-ring" :class="[draggableItems && !loading && 'cursor-grab', showResizeHandle(record) && 'pr-4']" :title="itemLabel(record)" :aria-label="itemLabel(record)" :disabled="loading" :data-gantt-item-key="record.key" @pointerdown.stop="startItemInteraction('move', lane, record, $event)" @dragstart.prevent @keydown="onItemKey(lane, record, $event)" @click="selectItem(lane, record, $event)" data-gantt-item>
|
|
764
|
+
<slot name="item" :item="record.item" :row="lane.row" :start-date="new Date(record.start)" :end-date="new Date(record.end)"><span class="truncate">{{ record.title }}</span></slot>
|
|
765
|
+
</button>
|
|
766
|
+
<button v-if="showResizeHandle(record)" type="button" class="absolute inset-y-0 right-0 flex w-3 touch-none cursor-ew-resize items-center justify-center rounded-r text-muted-fg/35 transition-colors hover:bg-canvas-fg/5 hover:text-muted-fg group-focus-within:text-muted-fg/65 group-hover:text-muted-fg/65 focus-visible:outline-2 focus-visible:outline-ring" :disabled="loading" :aria-label="`Resize ${record.title || 'item'}`" title="Drag to change duration. Use left/right arrows when focused." :data-gantt-item-key="record.key" @pointerdown.stop="startItemInteraction('resize', lane, record, $event)" @keydown="onResizeKey(lane, record, $event)" @click.stop data-gantt-resize>
|
|
767
|
+
<svg width="6" height="14" viewBox="0 0 6 14" fill="currentColor" aria-hidden="true"><circle v-for="dot in 6" :key="dot" :cx="dot % 2 ? 1 : 5" :cy="2 + Math.floor((dot - 1) / 2) * 5" r="0.85" /></svg>
|
|
768
|
+
</button>
|
|
769
|
+
</div>
|
|
770
|
+
<div v-if="itemPreview?.lane.key === lane.key" class="pointer-events-none absolute z-30 flex h-8 items-center overflow-hidden rounded-md border-2 border-primary bg-canvas px-2 text-xs font-semibold text-canvas-fg shadow-md ring-2 ring-primary/15" :style="itemStyle(itemPreview)" data-gantt-item-preview><span class="truncate">{{ itemPreview.title }}</span></div>
|
|
771
|
+
<div v-if="preview?.lane.key === lane.key" class="pointer-events-none absolute inset-y-1 z-20 rounded border-2 border-primary bg-primary/20" :style="{ left: `${preview.left}px`, width: `${preview.width}px` }" data-gantt-selection />
|
|
772
|
+
</div>
|
|
773
|
+
</div>
|
|
774
|
+
<div v-if="currentX !== null" class="pointer-events-none absolute inset-y-0 z-20 w-px bg-destructive" :style="{ left: `calc(var(--gantt-label-width) + ${currentX}px)` }" aria-hidden="true" data-gantt-now><span class="absolute left-1/2 top-0 size-2 -translate-x-1/2 rounded-full bg-destructive"></span></div>
|
|
775
|
+
</div>
|
|
776
|
+
</div>
|
|
777
|
+
<div v-if="!lanes.length" class="sticky left-0 px-4 py-12 text-center text-sm text-muted-fg"><slot name="empty">{{ loading ? 'Loading schedule…' : 'No rows yet. Turn on Plan mode to add your first row.' }}</slot></div>
|
|
778
|
+
<p v-else-if="!axis.width" class="sticky left-0 p-4 text-sm text-muted-fg">All dates in this range are hidden. Show weekends or choose another range.</p>
|
|
779
|
+
</div>
|
|
780
|
+
<form v-if="planning" class="flex flex-wrap items-end gap-2 border-t border-border bg-secondary/15 p-3" @submit.prevent="createRow">
|
|
781
|
+
<label class="sr-only" :for="`${helpId}-row-name`">New row name</label>
|
|
782
|
+
<DomTextInput :id="`${helpId}-row-name`" v-model="rowName" class="min-w-0 flex-1 basis-44" placeholder="New row name…" :disabled="loading" />
|
|
783
|
+
<DomButton type="submit" size="sm" variant="secondary" :disabled="!rowName.trim() || loading">{{ addRowLabel }}</DomButton>
|
|
784
|
+
</form>
|
|
785
|
+
<p v-if="itemInteraction?.active" class="border-t border-border bg-primary/5 px-3 py-2 text-xs text-muted-fg" aria-live="polite">{{ itemPreview ? `${itemPreview.lane.label}: ${new Date(itemPreview.start).toLocaleString('en-GB')} – ${new Date(itemPreview.end).toLocaleString('en-GB')}` : 'Move over a timeline row to drop.' }} Escape to cancel.</p>
|
|
786
|
+
<p class="sr-only" aria-live="polite">{{ preview ? `${new Date(preview.start).toLocaleString('en-GB')} to ${new Date(preview.end).toLocaleString('en-GB')}` : '' }}</p>
|
|
787
|
+
</section>
|
|
788
|
+
</template>
|