diff --git a/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts b/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts index 5b0721847d19..8c73d752b9b6 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts @@ -133,6 +133,12 @@ class SchedulerAppointments extends CollectionWidget { _supportedKeys() { const parent = super._supportedKeys(); + const setActiveAppointment = function ($appointment: dxElementWrapper) { + this._resetTabIndex($appointment); + // @ts-expect-error + eventsEngine.trigger($appointment, 'focus'); + }; + const tabHandler = function (e) { const navigatableItems = this._getNavigatableItems(); const focusedItem = navigatableItems.filter('.dx-state-focused'); @@ -150,9 +156,225 @@ class SchedulerAppointments extends CollectionWidget { $nextAppointment = this._getNavigatableItemByIndex(index); } - this._resetTabIndex($nextAppointment); - // @ts-expect-error - eventsEngine.trigger($nextAppointment, 'focus'); + if ($nextAppointment) { + setActiveAppointment.call(this, $nextAppointment); + } + } + }; + + const resizeHandler = function (e: KeyboardEvent, direction: 'up' | 'down' | 'left' | 'right') { + e.preventDefault(); + e.stopPropagation(); + + const navigatableItems = this._getNavigatableItems(); + const focusedItem = navigatableItems.filter('.dx-state-focused'); + + if (!focusedItem.length) { + return; + } + + const $appointment = focusedItem; + if (!this.option('allowResize')) { + return; + } + + const appointmentSettings = $appointment.data(APPOINTMENT_SETTINGS_KEY); + if (!appointmentSettings) { + return; + } + + const isAllDay = appointmentSettings.allDay; + const renderingStrategyDirection = this.invoke('getRenderingStrategyDirection'); + const isVertical = renderingStrategyDirection === 'vertical' && !isAllDay; + const isRTL = this.option('rtlEnabled'); + + const cellWidth = this.invoke('getCellWidth'); + const cellHeight = this.invoke('getCellHeight'); + const step = isVertical ? cellHeight : cellWidth; + + const handles: Record<'top' | 'bottom' | 'left' | 'right', boolean> = { + top: false, + bottom: false, + left: false, + right: false, + }; + let deltaWidth = 0; + let deltaHeight = 0; + + switch (true) { + case isVertical && direction === 'up': + handles.top = true; + deltaHeight = step; + break; + case isVertical && direction === 'down': + handles.bottom = true; + deltaHeight = step; + break; + case !isVertical && direction === 'left': + handles[isRTL ? 'right' : 'left'] = true; + deltaWidth = step; + break; + case !isVertical && direction === 'right': + handles[isRTL ? 'left' : 'right'] = true; + deltaWidth = step; + break; + default: + return; + } + + const currentRect = getBoundingRect($appointment[0]); + + if (!this._initialSize) { + this._initialSize = { width: currentRect.width, height: currentRect.height }; + this._initialCoordinates = locate($appointment); + this.resizeOccur = true; + this._$currentAppointment = $appointment; + } + + const initialWidth = this._initialSize.width; + const initialHeight = this._initialSize.height; + + const newWidth = initialWidth + deltaWidth; + const newHeight = initialHeight + deltaHeight; + + const resizeEvent = { + element: $appointment[0], + handles, + width: newWidth, + height: newHeight, + }; + + this._resizeEndHandler(resizeEvent, ($updatedElement) => { + if ($updatedElement && $updatedElement.length) { + setActiveAppointment.call(this, $updatedElement[0]); + } + }); + }; + + const arrowKeyHandler = function (e: KeyboardEvent, direction: 'up' | 'down' | 'left' | 'right') { + if (e.shiftKey) { + resizeHandler.call(this, e, direction); + return; + } + + const navigatableItems = this._getNavigatableItems(); + const focusedItem = navigatableItems.filter('.dx-state-focused'); + + if (!focusedItem.length) { + return; + } + + interface Bounds { + left: number; + right: number; + top: number; + bottom: number; + centerX: number; + centerY: number; + } + + const createBounds = (rect: DOMRect): Bounds => ({ + left: rect.left, + right: rect.left + rect.width, + top: rect.top, + bottom: rect.top + rect.height, + centerX: rect.left + rect.width / 2, + centerY: rect.top + rect.height / 2, + }); + + const calculateOverlap = (focusedBounds: Bounds, itemBounds: Bounds, axis: 'horizontal' | 'vertical'): number => { + if (axis === 'horizontal') { + return Math.max(0, Math.min(focusedBounds.right, itemBounds.right) - Math.max(focusedBounds.left, itemBounds.left)); + } + return Math.max(0, Math.min(focusedBounds.bottom, itemBounds.bottom) - Math.max(focusedBounds.top, itemBounds.top)); + }; + + const calculatePerpendicularDistance = (focusedBounds: Bounds, itemBounds: Bounds, axis: 'horizontal' | 'vertical'): number => ( + axis === 'horizontal' + ? Math.abs(focusedBounds.centerY - itemBounds.centerY) + : Math.abs(focusedBounds.centerX - itemBounds.centerX) + ); + + const focusedBounds = createBounds(getBoundingRect(focusedItem[0])); + + const directionConfig: Record<'up' | 'down' | 'left' | 'right', { + isValid: (item: Bounds) => boolean; + getDistance: (item: Bounds) => number; + overlapAxis: 'horizontal' | 'vertical'; + }> = { + up: { + isValid: (item: Bounds) => item.bottom <= focusedBounds.top, + getDistance: (item: Bounds) => focusedBounds.top - item.bottom, + overlapAxis: 'horizontal', + }, + down: { + isValid: (item: Bounds) => item.top >= focusedBounds.bottom, + getDistance: (item: Bounds) => item.top - focusedBounds.bottom, + overlapAxis: 'horizontal', + }, + left: { + isValid: (item: Bounds) => item.right <= focusedBounds.left, + getDistance: (item: Bounds) => focusedBounds.left - item.right, + overlapAxis: 'vertical', + }, + right: { + isValid: (item: Bounds) => item.left >= focusedBounds.right, + getDistance: (item: Bounds) => item.left - focusedBounds.right, + overlapAxis: 'vertical', + }, + }; + + const config = directionConfig[direction]; + + let bestCandidate: dxElementWrapper | null = null; + let bestDistance = Infinity; + + navigatableItems.each((_, item) => { + const $item = $(item); + if ($item.is(focusedItem)) { + return; + } + + const itemBounds = createBounds(getBoundingRect(item)); + + if (!config.isValid(itemBounds)) { + return; + } + + const overlap = calculateOverlap(focusedBounds, itemBounds, config.overlapAxis); + let distance = config.getDistance(itemBounds); + + if (overlap === 0) { + distance += calculatePerpendicularDistance(focusedBounds, itemBounds, config.overlapAxis); + } + + if (distance < bestDistance) { + bestDistance = distance; + bestCandidate = $item; + } + }); + + if (bestCandidate) { + e.preventDefault(); + e.stopPropagation(); + setActiveAppointment.call(this, bestCandidate); + } + }; + + const homeEndHandler = function (e: KeyboardEvent, goToFirst: boolean) { + const navigatableItems = this._getNavigatableItems(); + if (!navigatableItems.length) { + return; + } + + const $targetAppointment = goToFirst + ? navigatableItems.first() + : navigatableItems.last(); + + if ($targetAppointment.length) { + e.preventDefault(); + e.stopPropagation(); + setActiveAppointment.call(this, $targetAppointment); } }; @@ -176,6 +398,34 @@ class SchedulerAppointments extends CollectionWidget { } }.bind(this), tab: tabHandler, + upArrow: function (e) { + arrowKeyHandler.call(this, e, 'up'); + }.bind(this), + downArrow: function (e) { + arrowKeyHandler.call(this, e, 'down'); + }.bind(this), + leftArrow: function (e) { + arrowKeyHandler.call(this, e, 'left'); + }.bind(this), + rightArrow: function (e) { + arrowKeyHandler.call(this, e, 'right'); + }.bind(this), + home: function (e: KeyboardEvent) { + if (!e.ctrlKey) { + homeEndHandler.call(this, e, true); + } + }.bind(this), + end: function (e: KeyboardEvent) { + if (!e.ctrlKey) { + homeEndHandler.call(this, e, false); + } + }.bind(this), + ctrlHome: function (e: KeyboardEvent) { + homeEndHandler.call(this, e, true); + }.bind(this), + ctrlEnd: function (e: KeyboardEvent) { + homeEndHandler.call(this, e, false); + }.bind(this), }); } @@ -767,7 +1017,7 @@ class SchedulerAppointments extends CollectionWidget { }) || area; } - _resizeEndHandler(e) { + _resizeEndHandler(e, onUpdated?: ($updatedElement: dxElementWrapper) => void) { const $element = $(e.element); const { allDay, info } = $element.data(APPOINTMENT_SETTINGS_KEY) as any; @@ -793,6 +1043,11 @@ class SchedulerAppointments extends CollectionWidget { dateRange, this.dataAccessors, this.option('timeZoneCalculator'), + ($updatedElement) => { + if (onUpdated) { + onUpdated($updatedElement); + } + }, ); } @@ -823,6 +1078,7 @@ class SchedulerAppointments extends CollectionWidget { dateRange: { startDate: Date; endDate: Date }, dataAccessors: AppointmentDataAccessor, timeZoneCalculator, + onUpdated?: ($updatedElement: dxElementWrapper) => void, ) { const sourceAppointment = (this as any)._getItemData($element); const gridAdapter = new AppointmentAdapter( @@ -857,6 +1113,7 @@ class SchedulerAppointments extends CollectionWidget { target: sourceAppointment, data, $appointment: $element, + onUpdated, }); } diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index d4bba916e5ae..569d89084a56 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -991,7 +991,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { this._a11yStatus = createA11yStatusContainer(); this._a11yStatus.prependTo(this.$element()); // @ts-expect-error - this.setAria({ role: 'group' }); + this.setAria({ role: 'application' }); } _initMarkupOnResourceLoaded() { diff --git a/packages/devextreme/js/__internal/scheduler/m_subscribes.ts b/packages/devextreme/js/__internal/scheduler/m_subscribes.ts index 5499536f3102..d6d8f01ce91e 100644 --- a/packages/devextreme/js/__internal/scheduler/m_subscribes.ts +++ b/packages/devextreme/js/__internal/scheduler/m_subscribes.ts @@ -67,10 +67,15 @@ const subscribes = { updateAppointmentAfterResize(options) { const { info } = utils.dataAccessors.getAppointmentSettings(options.$appointment) as AppointmentItemViewModel; const { startDate } = info.sourceAppointment; + const { onUpdated, target, data } = options; - this._checkRecurringAppointment(options.target, options.data, startDate, () => { - this._updateAppointment(options.target, options.data, function () { + this._checkRecurringAppointment(target, data, startDate, () => { + this._updateAppointment(target, data, function () { this._appointments.moveAppointmentBack(); + }).always((storeAppointment) => { + if (onUpdated && storeAppointment) { + onUpdated(this._appointments._findItemElementByItem(storeAppointment)); + } }); }); }, diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts index c1649f57b664..d574c26729f9 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts @@ -2473,7 +2473,8 @@ class SchedulerWorkSpace extends Widget { (this.$element() as any) .addClass(COMPONENT_CLASS) - .addClass(this._getElementClass()); + .addClass(this._getElementClass()) + .attr('role', 'grid'); } _initPositionHelper() {