diff --git a/src/components/Proposal/ProposalDateItem.vue b/src/components/Proposal/ProposalDateItem.vue index 1a25165d82..2f90f7fdbd 100644 --- a/src/components/Proposal/ProposalDateItem.vue +++ b/src/components/Proposal/ProposalDateItem.vue @@ -8,13 +8,23 @@
-
+
{{ formattedDate }}
- + :aria-label="t('calendar', 'Remove date {date}', { date: formattedDate })" + @click="$emit('dateRemove')"> + +
@@ -84,7 +94,7 @@ export default { align-items: center; gap: calc(var(--default-grid-baseline) * 4); padding: var(--default-grid-baseline); - transition: background-color 0.2s ease; + transition: background-color 0.2s ease; &:hover { background-color: var(--color-background-hover); @@ -102,9 +112,27 @@ export default { text-overflow: ellipsis; white-space: nowrap; cursor: pointer; + border-radius: var(--border-radius-element); } .proposal-date__action { flex-shrink: 0; } + +.proposal-date__remove-btn { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + cursor: pointer; + padding: calc(var(--default-grid-baseline) * 1); + border-radius: var(--border-radius-element); + color: var(--color-main-text); + line-height: 0; + + &:hover { + background-color: var(--color-background-hover); + } +} diff --git a/src/views/Proposal/ProposalEditor.vue b/src/views/Proposal/ProposalEditor.vue index 96df4e4b61..11471226b6 100644 --- a/src/views/Proposal/ProposalEditor.vue +++ b/src/views/Proposal/ProposalEditor.vue @@ -101,6 +101,37 @@ class="proposal-editor__proposal-participants-selector" :alreadyInvitedEmails="existingParticipantAddressess" @addAttendee="onProposalParticipantAdd" /> + +
+
{{ t('calendar', 'Add a time slot') }}
+
+ + + + + {{ t('calendar', 'Add') }} + +
+
{{ t('calendar', 'Participants') }}
- + +
+

+ {{ keyboardCursorAriaLabel }} +

+ +
@@ -215,10 +260,10 @@ import PreviousSpanIcon from 'vue-material-design-icons/ChevronLeft' import NextSpanIcon from 'vue-material-design-icons/ChevronRight' import DurationIcon from 'vue-material-design-icons/ClockOutline' import ZoomOutIcon from 'vue-material-design-icons/MagnifyMinusOutline' -// icons import ZoomInIcon from 'vue-material-design-icons/MagnifyPlusOutline' import LocationIcon from 'vue-material-design-icons/MapMarkerOutline' import EditIcon from 'vue-material-design-icons/PencilOutline' +import AddIcon from 'vue-material-design-icons/Plus' import DeleteIcon from 'vue-material-design-icons/TrashCanOutline' // components import NcButton from '@nextcloud/vue/components/NcButton' @@ -283,6 +328,7 @@ export default { DeleteIcon, LocationIcon, DurationIcon, + AddIcon, }, data() { @@ -309,6 +355,10 @@ export default { pendingDeleteProposal: null as Proposal | null, showConvertDialog: false, pendingConvertDate: null as ProposalDate | null, + newSlotDate: '', + newSlotTime: '', + keyboardCursorDate: null as Date | null, // current keyboard cursor position + keyboardCursorActive: false, // whether the keyboard cursor is visible } }, @@ -476,6 +526,18 @@ export default { ] }, + keyboardCursorAriaLabel(): string { + if (!this.keyboardCursorDate) { + return '' + } + const duration = this.selectedProposal?.duration ?? 30 + const end = new Date(this.keyboardCursorDate.getTime() + duration * 60000) + return t('calendar', 'Selected: {start} – {end}. Press Enter to add this time slot.', { + start: this.formatProposalDate(this.keyboardCursorDate), + end: moment(end).format('LT'), + }) + }, + existingParticipantAddressess(): string[] { return this.selectedProposal ? this.selectedProposal.participants.map((p: ProposalParticipant) => p.address) : [] }, @@ -594,6 +656,8 @@ export default { this.selectedProposal = null this.modalView = 'view' this.participantAvailability = {} + this.keyboardCursorDate = null + this.keyboardCursorActive = false if (this.calendarApi) { this.calendarApi.removeAllEvents() this.calendarApi.unselect() @@ -710,6 +774,107 @@ export default { this.calendarApi.gotoDate(date.date) }, + onCalendarWrapperFocus(): void { + if (!this.keyboardCursorDate) { + // Initialize cursor to now (rounded to next 15 min) but not in the past + const now = new Date() + const minutes = Math.ceil(now.getMinutes() / 15) * 15 + now.setMinutes(minutes, 0, 0) + this.keyboardCursorDate = now + } + this.keyboardCursorActive = true + this.renderParticipantAvailability() + }, + + onCalendarWrapperBlur(): void { + this.keyboardCursorActive = false + this.renderParticipantAvailability() + }, + + onCalendarKeydown(event: KeyboardEvent): void { + // Only intercept arrow keys and Enter/Escape when wrapper is focused + const handled = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter', 'Escape'].includes(event.key) + if (!handled) { + return + } + event.preventDefault() + + if (!this.keyboardCursorDate) { + return + } + + const SLOT_MINUTES = 15 + const cursor = new Date(this.keyboardCursorDate.getTime()) + + switch (event.key) { + case 'ArrowRight': + cursor.setDate(cursor.getDate() + 1) + break + case 'ArrowLeft': + cursor.setDate(cursor.getDate() - 1) + break + case 'ArrowDown': + cursor.setMinutes(cursor.getMinutes() + SLOT_MINUTES) + break + case 'ArrowUp': + cursor.setMinutes(cursor.getMinutes() - SLOT_MINUTES) + break + case 'Enter': { + const duration = parseInt(String(this.selectedProposal?.duration ?? ''), 10) + if (isNaN(duration) || duration <= 0) { + showError(t('calendar', 'Please enter a valid duration in minutes.')) + return + } + this.addProposedDate(new Date(this.keyboardCursorDate.getTime())) + return + } + case 'Escape': + this.keyboardCursorActive = false + this.renderParticipantAvailability() + // Return focus to the wrapper's previous sibling or blur + ;(this.$refs.calendarKeyboardWrapper as HTMLElement)?.blur() + return + } + + // Prevent moving cursor to the past + const today = new Date() + today.setHours(0, 0, 0, 0) + if (cursor < today) { + return + } + + this.keyboardCursorDate = cursor + // Navigate calendar view to keep cursor visible + if (this.calendarApi) { + this.calendarApi.gotoDate(cursor) + } + this.renderParticipantAvailability() + }, + + onKeyboardAddSlot(): void { + if (!this.newSlotDate || !this.newSlotTime) { + return + } + const duration = parseInt(String(this.selectedProposal?.duration ?? ''), 10) + if (isNaN(duration) || duration <= 0) { + showError(t('calendar', 'Please enter a valid duration in minutes.')) + return + } + const dateObj = new Date(`${this.newSlotDate}T${this.newSlotTime}`) + if (isNaN(dateObj.getTime())) { + showError(t('calendar', 'Invalid date or time.')) + return + } + this.addProposedDate(dateObj) + // Navigate calendar to the selected date + if (this.calendarApi) { + this.calendarApi.gotoDate(dateObj) + } + // Reset inputs + this.newSlotDate = '' + this.newSlotTime = '' + }, + onCalendarFocusToday(): void { if (!this.calendarApi) { return console.error('Calendar API not initialized') @@ -1016,6 +1181,22 @@ export default { }, }) }) + + // Add keyboard cursor event + if (this.keyboardCursorActive && this.keyboardCursorDate) { + this.calendarApi.addEvent({ + id: 'keyboard-cursor', + title: t('calendar', 'Press Enter to add'), + start: this.keyboardCursorDate, + end: new Date(this.keyboardCursorDate.getTime() + duration * 60000), + backgroundColor: 'var(--color-success)', + borderColor: 'var(--color-success)', + allDay: false, + startEditable: false, + classNames: ['keyboard-cursor-event'], + extendedProps: { keyboardCursor: true }, + }) + } }, generateParticipantColor(participantId) { @@ -1040,6 +1221,8 @@ export default {