From 60ab760f9ccd2722efe9b8b70d632f146296db45 Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Sat, 31 Oct 2026 09:20:23 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20UidCalendar=20=E2=80=94=20day,=20we?= =?UTF-8?q?ek,=20year=20and=20agenda=20views,=20timed=20and=20multi-day=20?= =?UTF-8?q?events,=20drag=20and=20resize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .changeset/calendar-views.md | 5 + src/components/Calendar/UidCalendar.css | 476 ++++++++++++ src/components/Calendar/UidCalendar.spec.ts | 60 ++ .../Calendar/UidCalendar.stories.ts | 44 ++ src/components/Calendar/UidCalendar.vue | 720 ++++++++++++++---- src/components/Calendar/calendarModel.spec.ts | 52 ++ src/components/Calendar/calendarModel.ts | 173 +++++ src/locales/en.ts | 11 +- src/locales/ru.ts | 9 + src/locales/types.ts | 6 + 10 files changed, 1427 insertions(+), 129 deletions(-) create mode 100644 .changeset/calendar-views.md create mode 100644 src/components/Calendar/calendarModel.spec.ts create mode 100644 src/components/Calendar/calendarModel.ts diff --git a/.changeset/calendar-views.md b/.changeset/calendar-views.md new file mode 100644 index 0000000..54fd67c --- /dev/null +++ b/.changeset/calendar-views.md @@ -0,0 +1,5 @@ +--- +'@dskripchenko/ui': minor +--- + +UidCalendar: day, week, year and agenda views besides the month (`v-model:view`, `views` for the switcher, `v-model:anchor` for the shown date), events with a time (`time`, `endTime`) and across days (`endDate`), overlapping events side by side in the time grid, a `range` event with the visible days, and with `editable` drag-to-move, resize and `slot-click` on an empty slot (`event-move`). The month view stays as before when no other view is asked for. diff --git a/src/components/Calendar/UidCalendar.css b/src/components/Calendar/UidCalendar.css index 7054cfc..04246f5 100644 --- a/src/components/Calendar/UidCalendar.css +++ b/src/components/Calendar/UidCalendar.css @@ -30,6 +30,50 @@ gap: var(--uid-space-xs); } +.uid-calendar__header { + flex-wrap: wrap; + gap: var(--uid-space-sm); +} + +.uid-calendar__tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--uid-space-sm); +} + +.uid-calendar__views { + display: flex; +} + +.uid-calendar__view { + border-radius: 0; + margin-left: -1px; +} + +.uid-calendar__view:first-child { + margin-left: 0; + border-radius: var(--uid-radius-sm) 0 0 var(--uid-radius-sm); +} + +.uid-calendar__view:last-child { + border-radius: 0 var(--uid-radius-sm) var(--uid-radius-sm) 0; +} + +.uid-calendar__view--active, +.uid-calendar__view--active:hover { + position: relative; + background: var(--uid-color-primary-subtle); + border-color: var(--uid-color-primary); + color: var(--uid-color-primary); +} + +.uid-calendar__btn:focus-visible { + position: relative; + outline: 2px solid var(--uid-color-focus-ring); + outline-offset: 1px; +} + .uid-calendar__btn { display: flex; align-items: center; @@ -179,6 +223,438 @@ font-size: 11px; color: var(--uid-color-text-secondary); padding: 0 6px; + border: none; + background: none; + text-align: left; + cursor: pointer; +} + +.uid-calendar__more:hover { + color: var(--uid-color-text); + text-decoration: underline; +} + +.uid-calendar__event-label { + overflow: hidden; + text-overflow: ellipsis; +} + +.uid-calendar__event-time { + flex: none; + font-variant-numeric: tabular-nums; + opacity: 0.85; +} + +.uid-calendar__event--timed { + background: transparent; + color: var(--uid-color-text); +} + +.uid-calendar__event--timed::before { + content: ''; + flex: none; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--uid-event-color, var(--uid-color-primary)); +} + +.uid-calendar__event--timed:hover { + filter: none; + background: var(--uid-color-bg-subtle); +} + +.uid-calendar__event--from-prev { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + margin-left: -7px; + width: calc(100% + 7px); +} + +.uid-calendar__event--to-next { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + margin-right: -7px; + width: calc(100% + 7px); +} + +.uid-calendar__event--from-prev.uid-calendar__event--to-next { + width: calc(100% + 14px); +} + +.uid-calendar__day:has(.uid-calendar__event--to-next), +.uid-calendar__allday-cell:has(.uid-calendar__event--to-next) { + overflow: visible; +} + +.uid-calendar__event--draggable { + touch-action: none; +} + +.uid-calendar__event--dragging, +.uid-calendar__block--dragging { + opacity: 0.75; + box-shadow: 0 4px 12px rgb(0 0 0 / 0.18); + z-index: 3; +} + +.uid-calendar__event:focus-visible, +.uid-calendar__block:focus-visible, +.uid-calendar__agenda-item:focus-visible, +.uid-calendar__mini-day:focus-visible, +.uid-calendar__mini-title:focus-visible, +.uid-calendar__tg-day:focus-visible { + outline: 2px solid var(--uid-color-focus-ring); + outline-offset: 1px; +} + +/* Day and week */ +.uid-calendar__tg { + --uid-cal-gutter-w: 52px; + + display: flex; + flex-direction: column; + min-height: 0; +} + +.uid-calendar__tg-head, +.uid-calendar__allday, +.uid-calendar__tg-inner { + display: grid; + grid-template-columns: var(--uid-cal-gutter-w) repeat(var(--uid-cal-days, 7), minmax(0, 1fr)); +} + +.uid-calendar--day .uid-calendar__tg { + --uid-cal-days: 1; +} + +.uid-calendar__tg-head { + background: var(--uid-color-bg-subtle); + border-bottom: 1px solid var(--uid-color-border); +} + +.uid-calendar__tg-day { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 6px 0; + border: none; + border-left: 1px solid var(--uid-color-border); + background: none; + color: var(--uid-color-text); + cursor: pointer; +} + +.uid-calendar__tg-weekday { + font-size: var(--uid-font-size-xs); + color: var(--uid-color-text-tertiary); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.uid-calendar__tg-date { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + border-radius: 14px; + font-size: var(--uid-font-size-lg, 16px); + font-weight: var(--uid-font-weight-medium); + font-variant-numeric: tabular-nums; +} + +.uid-calendar__tg-day--today .uid-calendar__tg-date { + background: var(--uid-color-primary); + color: var(--uid-color-text-on-primary); +} + +.uid-calendar__allday { + border-bottom: 1px solid var(--uid-color-border); + min-height: 28px; +} + +.uid-calendar__gutter-label { + display: flex; + align-items: center; + justify-content: flex-end; + padding-right: 6px; + font-size: 10px; + color: var(--uid-color-text-tertiary); +} + +.uid-calendar__allday-cell { + display: flex; + flex-direction: column; + gap: 2px; + padding: 3px 6px; + border-left: 1px solid var(--uid-color-border); + min-width: 0; +} + +.uid-calendar__tg-body { + overflow-y: auto; + max-height: var(--uid-cal-grid-h, 560px); +} + +.uid-calendar__tg-inner { + position: relative; + height: calc(24 * var(--uid-cal-hour-h, 48px)); + margin-top: 8px; +} + +.uid-calendar__hours { + position: relative; +} + +.uid-calendar__hour { + display: block; + height: var(--uid-cal-hour-h, 48px); + padding-right: 6px; + font-size: 10px; + font-variant-numeric: tabular-nums; + text-align: right; + color: var(--uid-color-text-tertiary); + transform: translateY(-6px); +} + +.uid-calendar__col { + position: relative; + border-left: 1px solid var(--uid-color-border); + background-image: linear-gradient(to bottom, var(--uid-color-border) 1px, transparent 1px); + background-size: 100% var(--uid-cal-hour-h, 48px); +} + +.uid-calendar--editable .uid-calendar__col { + cursor: cell; +} + +.uid-calendar__col--today { + background-color: color-mix(in srgb, var(--uid-color-primary) 4%, transparent); +} + +.uid-calendar__now { + position: absolute; + left: 0; + right: 0; + z-index: 2; + height: 2px; + background: var(--uid-color-danger, #dc2626); + pointer-events: none; +} + +.uid-calendar__now::before { + content: ''; + position: absolute; + left: -4px; + top: -3px; + width: 8px; + height: 8px; + border-radius: 50%; + background: inherit; +} + +.uid-calendar__block { + position: absolute; + z-index: 1; + display: flex; + flex-direction: column; + gap: 1px; + padding: 2px 6px; + overflow: hidden; + border-radius: var(--uid-radius-sm); + border-left: 3px solid var(--uid-event-color, var(--uid-color-primary)); + background: color-mix(in srgb, var(--uid-event-color, var(--uid-color-primary)) 18%, var(--uid-color-bg)); + color: var(--uid-color-text); + font-size: 11px; + line-height: 1.3; + cursor: pointer; + box-sizing: border-box; +} + +.uid-calendar--editable .uid-calendar__block { + touch-action: none; +} + +.uid-calendar__block-time { + font-variant-numeric: tabular-nums; + color: var(--uid-color-text-secondary); +} + +.uid-calendar__block-title { + font-weight: var(--uid-font-weight-medium); + overflow: hidden; + text-overflow: ellipsis; +} + +.uid-calendar__block--short { + flex-direction: row; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.uid-calendar__resize { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 6px; + cursor: ns-resize; +} + +/* Year */ +.uid-calendar__year { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--uid-space-md); + padding: var(--uid-space-md); +} + +.uid-calendar__mini-title { + display: block; + margin-bottom: 4px; + padding: 2px 4px; + border: none; + background: none; + font-size: var(--uid-font-size-sm); + font-weight: var(--uid-font-weight-semibold); + color: var(--uid-color-text); + cursor: pointer; + border-radius: var(--uid-radius-sm); +} + +.uid-calendar__mini-title:hover { + background: var(--uid-color-bg-subtle); +} + +.uid-calendar__mini-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 1px; +} + +.uid-calendar__mini-wd { + font-size: 10px; + text-align: center; + color: var(--uid-color-text-tertiary); +} + +.uid-calendar__mini-day { + position: relative; + justify-self: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 12px; + background: none; + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--uid-color-text); + cursor: pointer; +} + +.uid-calendar__mini-day:hover:not(:disabled) { + background: var(--uid-color-bg-subtle); +} + +.uid-calendar__mini-day--other { + visibility: hidden; +} + +.uid-calendar__mini-day--busy { + font-weight: var(--uid-font-weight-semibold); + color: var(--uid-color-primary); +} + +.uid-calendar__mini-day--busy::after { + content: ''; + position: absolute; + left: 50%; + bottom: 2px; + width: 4px; + height: 4px; + margin-left: -2px; + border-radius: 50%; + background: var(--uid-color-primary); +} + +.uid-calendar__mini-day--today { + background: var(--uid-color-primary); + color: var(--uid-color-text-on-primary); +} + +.uid-calendar__mini-day--today.uid-calendar__mini-day--busy::after { + background: var(--uid-color-text-on-primary); +} + +/* Agenda */ +.uid-calendar__agenda { + display: flex; + flex-direction: column; + padding: var(--uid-space-sm) 0; +} + +.uid-calendar__empty { + margin: 0; + padding: var(--uid-space-lg) var(--uid-space-md); + text-align: center; + color: var(--uid-color-text-tertiary); + font-size: var(--uid-font-size-sm); +} + +.uid-calendar__agenda-day { + display: flex; + flex-direction: column; + padding: var(--uid-space-xs) var(--uid-space-md); +} + +.uid-calendar__agenda-day + .uid-calendar__agenda-day { + border-top: 1px solid var(--uid-color-border); +} + +.uid-calendar__agenda-date { + margin: 4px 0; + font-size: var(--uid-font-size-sm); + font-weight: var(--uid-font-weight-semibold); + color: var(--uid-color-text); +} + +.uid-calendar__agenda-day--today .uid-calendar__agenda-date { + color: var(--uid-color-primary); +} + +.uid-calendar__agenda-item { + display: grid; + grid-template-columns: 96px 10px 1fr; + align-items: center; + gap: var(--uid-space-sm); + padding: 6px 4px; + border: none; + border-radius: var(--uid-radius-sm); + background: none; + color: var(--uid-color-text); + font-size: var(--uid-font-size-sm); + text-align: left; + cursor: pointer; +} + +.uid-calendar__agenda-item:hover { + background: var(--uid-color-bg-subtle); +} + +.uid-calendar__agenda-time { + font-variant-numeric: tabular-nums; + color: var(--uid-color-text-secondary); +} + +.uid-calendar__agenda-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--uid-event-color, var(--uid-color-primary)); } /* Compact */ diff --git a/src/components/Calendar/UidCalendar.spec.ts b/src/components/Calendar/UidCalendar.spec.ts index 3c7566b..19f72ba 100644 --- a/src/components/Calendar/UidCalendar.spec.ts +++ b/src/components/Calendar/UidCalendar.spec.ts @@ -108,4 +108,64 @@ describe('UidCalendar', () => { await apr10?.trigger('keydown', { key: 'Enter' }) expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['2026-04-10']) }) + + it('по умолчанию без переключателя видов', () => { + const wrapper = mount(UidCalendar, { props: { modelValue: '2026-04-15' } }) + expect(wrapper.find('.uid-calendar__views').exists()).toBe(false) + }) + + it('переключает вид и сообщает видимый диапазон', async () => { + const wrapper = mount(UidCalendar, { props: { anchor: '2026-11-04', views: ['day', 'week', 'month', 'year', 'agenda'] } }) + expect(wrapper.emitted('range')?.[0]).toEqual([{ view: 'month', start: '2026-10-26', end: '2026-12-06' }]) + await wrapper.findAll('.uid-calendar__view')[1].trigger('click') + expect(wrapper.emitted('update:view')?.[0]).toEqual(['week']) + }) + + it('неделя: события со временем в колонках, многодневные — в строке «весь день»', () => { + const wrapper = mount(UidCalendar, { + props: { + view: 'week', + anchor: '2026-11-04', + events: [ + { date: '2026-11-03', time: '10:00', endTime: '11:00', title: 'Демо' }, + { date: '2026-11-02', endDate: '2026-11-06', title: 'Код-фриз' }, + ], + }, + }) + expect(wrapper.findAll('.uid-calendar__col')).toHaveLength(7) + expect(wrapper.find('.uid-calendar__col[data-date="2026-11-03"] .uid-calendar__block').text()).toContain('Демо') + expect(wrapper.findAll('.uid-calendar__allday-cell .uid-calendar__event')).toHaveLength(5) + expect(wrapper.find('.uid-calendar__title').text()).toBe('2 – 8 ноября 2026') + }) + + it('день: заголовок с днём недели', () => { + const wrapper = mount(UidCalendar, { props: { view: 'day', anchor: '2026-11-04' } }) + expect(wrapper.findAll('.uid-calendar__col')).toHaveLength(1) + expect(wrapper.find('.uid-calendar__title').text()).toBe('Среда, 4 ноября 2026') + }) + + it('год: 12 месяцев, дни с событиями отмечены', () => { + const wrapper = mount(UidCalendar, { + props: { view: 'year', anchor: '2026-05-01', events: [{ date: '2026-03-08', title: 'x' }] }, + }) + expect(wrapper.findAll('.uid-calendar__mini')).toHaveLength(12) + expect(wrapper.find('.uid-calendar__mini-day--busy').attributes('aria-label')).toBe('2026-03-08') + }) + + it('повестка: дни с событиями и пустое состояние', async () => { + const wrapper = mount(UidCalendar, { + props: { view: 'agenda', anchor: '2026-11-01', events: [{ date: '2026-11-03', time: '10:00', endTime: '11:00', title: 'Демо' }] }, + }) + expect(wrapper.find('.uid-calendar__agenda-date').text()).toBe('Вторник, 3 ноября') + expect(wrapper.find('.uid-calendar__agenda-time').text()).toBe('10:00–11:00') + await wrapper.setProps({ anchor: '2026-12-01' }) + expect(wrapper.find('.uid-calendar__empty').exists()).toBe(true) + }) + + it('клик по пустому месту сетки — slot-click только в режиме правки', async () => { + const wrapper = mount(UidCalendar, { props: { view: 'day', anchor: '2026-11-04', editable: true } }) + await wrapper.find('.uid-calendar__col').trigger('click', { clientY: 0 }) + expect(wrapper.emitted('slot-click')?.[0]).toEqual([{ date: '2026-11-04', time: '00:00' }]) + }) }) + diff --git a/src/components/Calendar/UidCalendar.stories.ts b/src/components/Calendar/UidCalendar.stories.ts index 91c8592..949acf6 100644 --- a/src/components/Calendar/UidCalendar.stories.ts +++ b/src/components/Calendar/UidCalendar.stories.ts @@ -73,3 +73,47 @@ export const MinMax: Story = { `, }), } + +const planning: CalendarEvent[] = [ + { id: 1, date: '2026-11-02', time: '11:00', endTime: '11:30', title: 'Планирование спринта', color: '#3b82f6' }, + { id: 2, date: '2026-11-03', time: '10:00', endTime: '11:00', title: 'Демо релиза 3.0', color: '#10b981' }, + { id: 3, date: '2026-11-03', time: '10:30', endTime: '12:00', title: 'Разбор инцидента', color: '#ef4444' }, + { id: 4, date: '2026-11-03', time: '14:00', endTime: '14:30', title: '1:1', color: '#8b5cf6' }, + { id: 5, date: '2026-11-05', time: '16:00', endTime: '17:00', title: 'Ретро', color: '#f59e0b' }, + { id: 6, date: '2026-11-10', endDate: '2026-11-14', title: 'Код-фриз', color: '#ef4444' }, + { id: 7, date: '2026-11-04', title: 'Дежурство: Анна' }, + { id: 8, date: '2026-11-27', title: 'Релиз в прод', color: '#10b981' }, +] + +export const Views: Story = { + name: 'Виды: день, неделя, месяц, год, повестка', + render: () => ({ + components: { UidCalendar }, + setup: () => ({ + events: planning, + view: ref<'day' | 'week' | 'month' | 'year' | 'agenda'>('week'), + anchor: ref('2026-11-04'), + views: ['day', 'week', 'month', 'year', 'agenda'], + }), + template: ``, + }), +} + +export const Editable: Story = { + name: 'Правка: перенос, растягивание, новое событие', + render: () => ({ + components: { UidCalendar }, + setup: () => { + const events = ref(planning.map((e) => ({ ...e }))) + let next = 100 + const onMove = (m: { event: CalendarEvent; date: string; endDate?: string; time?: string; endTime?: string }) => { + events.value = events.value.map((e) => (e === m.event ? { ...e, date: m.date, endDate: m.endDate, time: m.time ?? e.time, endTime: m.endTime ?? e.endTime } : e)) + } + const onSlot = (s: { date: string; time?: string }) => { + events.value = [...events.value, { id: next++, date: s.date, time: s.time, title: 'Новое событие' }] + } + return { events, onMove, onSlot, views: ['day', 'week', 'month'], view: ref('week'), anchor: ref('2026-11-04') } + }, + template: ``, + }), +} diff --git a/src/components/Calendar/UidCalendar.vue b/src/components/Calendar/UidCalendar.vue index 4c63511..5c491d6 100644 --- a/src/components/Calendar/UidCalendar.vue +++ b/src/components/Calendar/UidCalendar.vue @@ -1,15 +1,43 @@ diff --git a/src/components/Calendar/calendarModel.spec.ts b/src/components/Calendar/calendarModel.spec.ts new file mode 100644 index 0000000..a8f1b48 --- /dev/null +++ b/src/components/Calendar/calendarModel.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { eventsOn, layoutDay, moved, rangeOf, step } from './calendarModel' + +describe('calendarModel', () => { + it('диапазоны видов', () => { + expect(rangeOf('day', '2026-11-04')).toEqual({ view: 'day', start: '2026-11-04', end: '2026-11-04' }) + expect(rangeOf('week', '2026-11-04')).toEqual({ view: 'week', start: '2026-11-02', end: '2026-11-08' }) + expect(rangeOf('month', '2026-11-15')).toEqual({ view: 'month', start: '2026-10-26', end: '2026-12-06' }) + expect(rangeOf('year', '2026-11-15')).toEqual({ view: 'year', start: '2026-01-01', end: '2026-12-31' }) + expect(rangeOf('agenda', '2026-02-10')).toEqual({ view: 'agenda', start: '2026-02-01', end: '2026-02-28' }) + }) + + it('шаг вида', () => { + expect(step('day', '2026-12-31', 1)).toBe('2027-01-01') + expect(step('week', '2026-11-04', -1)).toBe('2026-10-28') + expect(step('month', '2026-01-31', 1)).toBe('2026-02-01') + expect(step('year', '2026-05-10', -1)).toBe('2025-05-01') + }) + + it('события дня: многодневные и на весь день первыми, затем по времени', () => { + const events = [ + { date: '2026-11-03', time: '14:00', title: 'b' }, + { date: '2026-11-03', time: '09:30', title: 'a' }, + { date: '2026-11-01', endDate: '2026-11-05', title: 'freeze' }, + { date: '2026-11-04', title: 'other day' }, + ] + expect(eventsOn(events, '2026-11-03').map((e) => e.title)).toEqual(['freeze', 'a', 'b']) + }) + + it('пересекающиеся события делят ширину', () => { + const blocks = layoutDay([ + { date: '2026-11-03', time: '10:00', endTime: '11:00', title: 'a' }, + { date: '2026-11-03', time: '10:30', endTime: '12:00', title: 'b' }, + { date: '2026-11-03', time: '11:00', endTime: '11:30', title: 'c' }, + { date: '2026-11-03', time: '13:00', title: 'd' }, + ], '2026-11-03') + const by = Object.fromEntries(blocks.map((b) => [b.event.title, [b.col, b.cols, b.start, b.end]])) + expect(by.a).toEqual([0, 2, 600, 660]) + expect(by.b).toEqual([1, 2, 630, 720]) + expect(by.c).toEqual([0, 2, 660, 690]) + expect(by.d).toEqual([0, 1, 780, 840]) + }) + + it('перенос сохраняет длительность', () => { + expect(moved({ date: '2026-11-03', time: '10:00', endTime: '11:30', title: 'x' }, '2026-11-05', '15:00')) + .toEqual({ date: '2026-11-05', time: '15:00', endTime: '16:30' }) + expect(moved({ date: '2026-11-10', endDate: '2026-11-14', title: 'freeze' }, '2026-11-17')) + .toEqual({ date: '2026-11-17', endDate: '2026-11-21' }) + expect(moved({ date: '2026-11-03', time: '10:00', title: 'x' }, '2026-11-04')) + .toEqual({ date: '2026-11-04', time: '10:00' }) + }) +}) diff --git a/src/components/Calendar/calendarModel.ts b/src/components/Calendar/calendarModel.ts new file mode 100644 index 0000000..df4dc36 --- /dev/null +++ b/src/components/Calendar/calendarModel.ts @@ -0,0 +1,173 @@ + +export type CalendarView = 'day' | 'week' | 'month' | 'year' | 'agenda' + +export interface CalendarEvent { + id?: string | number + date: string + endDate?: string + time?: string + endTime?: string + title: string + color?: string +} + +export interface CalendarRange { + view: CalendarView + start: string + end: string +} + +export function toISO(d: Date): string { + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${d.getFullYear()}-${m}-${day}` +} + +export function fromISO(iso: string): Date { + const [y, m, d] = iso.split('-').map(Number) + return new Date(y, m - 1, d) +} + +export function addDays(iso: string, n: number): string { + const d = fromISO(iso) + d.setDate(d.getDate() + n) + return toISO(d) +} + +export function daysBetween(a: string, b: string): number { + return Math.round((fromISO(b).getTime() - fromISO(a).getTime()) / 86400000) +} + +export function startOfWeek(iso: string): string { + const d = fromISO(iso) + return addDays(iso, -((d.getDay() + 6) % 7)) +} + +export function startOfMonth(iso: string): string { + return `${iso.slice(0, 7)}-01` +} + +export function endOfMonth(iso: string): string { + const d = fromISO(startOfMonth(iso)) + return toISO(new Date(d.getFullYear(), d.getMonth() + 1, 0)) +} + +export function minutes(time: string | undefined): number | null { + if (!time) return null + const m = /^(\d{1,2}):(\d{2})$/.exec(time) + if (!m) return null + return Math.min(24 * 60, Number(m[1]) * 60 + Number(m[2])) +} + +export function formatTime(min: number): string { + const h = Math.floor(min / 60) + return `${String(h).padStart(2, '0')}:${String(min % 60).padStart(2, '0')}` +} + +export function rangeOf(view: CalendarView, anchor: string): CalendarRange { + switch (view) { + case 'day': + return { view, start: anchor, end: anchor } + case 'week': { + const start = startOfWeek(anchor) + return { view, start, end: addDays(start, 6) } + } + case 'month': { + const start = startOfWeek(startOfMonth(anchor)) + return { view, start, end: addDays(start, 41) } + } + case 'year': { + const y = anchor.slice(0, 4) + return { view, start: `${y}-01-01`, end: `${y}-12-31` } + } + case 'agenda': + return { view, start: startOfMonth(anchor), end: endOfMonth(anchor) } + } +} + +export function step(view: CalendarView, anchor: string, dir: 1 | -1): string { + if (view === 'day') return addDays(anchor, dir) + if (view === 'week') return addDays(anchor, 7 * dir) + const d = fromISO(anchor) + if (view === 'year') return toISO(new Date(d.getFullYear() + dir, d.getMonth(), 1)) + return toISO(new Date(d.getFullYear(), d.getMonth() + dir, 1)) +} + +export function lastDay(e: CalendarEvent): string { + return e.endDate && e.endDate > e.date ? e.endDate : e.date +} + +export function isAllDay(e: CalendarEvent): boolean { + return minutes(e.time) === null +} + +export function isMultiDay(e: CalendarEvent): boolean { + return lastDay(e) !== e.date +} + +export function occursOn(e: CalendarEvent, iso: string): boolean { + return e.date <= iso && iso <= lastDay(e) +} + +export function eventsOn(events: readonly CalendarEvent[], iso: string): CalendarEvent[] { + return events + .filter((e) => occursOn(e, iso)) + .sort((a, b) => { + const ka = isAllDay(a) || isMultiDay(a) ? -1 : (minutes(a.time) ?? 0) + const kb = isAllDay(b) || isMultiDay(b) ? -1 : (minutes(b.time) ?? 0) + return ka - kb + }) +} + +export interface TimedBlock { + event: CalendarEvent + start: number + end: number + col: number + cols: number +} + +export function layoutDay(events: readonly CalendarEvent[], iso: string): TimedBlock[] { + const timed = events + .filter((e) => e.date === iso && !isAllDay(e) && !isMultiDay(e)) + .map((event) => { + const start = minutes(event.time) ?? 0 + const end = Math.max(start + 15, minutes(event.endTime) ?? start + 60) + return { event, start, end: Math.min(end, 24 * 60), col: 0, cols: 1 } + }) + .sort((a, b) => a.start - b.start || b.end - a.end) + let group: TimedBlock[] = [] + let groupEnd = -1 + const close = (): void => { + const cols = group.reduce((n, b) => Math.max(n, b.col + 1), 1) + for (const b of group) b.cols = cols + group = [] + } + for (const block of timed) { + if (block.start >= groupEnd) { + close() + groupEnd = -1 + } + const taken = new Set(group.filter((b) => b.end > block.start).map((b) => b.col)) + let col = 0 + while (taken.has(col)) col++ + block.col = col + group.push(block) + groupEnd = Math.max(groupEnd, block.end) + } + close() + return timed +} + +export function moved(e: CalendarEvent, date: string, time?: string): Pick { + const span = daysBetween(e.date, lastDay(e)) + const out: Pick = { date } + if (span > 0) out.endDate = addDays(date, span) + const from = minutes(e.time) + if (from === null) return out + const to = minutes(e.endTime) ?? from + 60 + const start = time !== undefined ? (minutes(time) ?? from) : from + out.time = formatTime(start) + if (e.endTime !== undefined || time !== undefined) out.endTime = formatTime(Math.min(24 * 60, start + (to - from))) + return out +} diff --git a/src/locales/en.ts b/src/locales/en.ts index 68141c5..692439e 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -227,6 +227,15 @@ export const en: UidLocale = { calendar: { today: 'Today', + views: { day: 'Day', week: 'Week', month: 'Month', year: 'Year', agenda: 'Agenda' }, + allDay: 'All day', + noEvents: 'No events', + more: (n) => `+${n}`, + monthsGenitive: [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', + ], + weekdaysLong: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], }, carousel: { @@ -264,7 +273,7 @@ export const en: UidLocale = { }, avatarGroup: { - more: (n) => `${n} more`, + more: (n) => `+${n}`, }, command: { diff --git a/src/locales/ru.ts b/src/locales/ru.ts index b5a3870..257e49e 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -227,6 +227,15 @@ export const ru: UidLocale = { calendar: { today: 'Сегодня', + views: { day: 'День', week: 'Неделя', month: 'Месяц', year: 'Год', agenda: 'Повестка' }, + allDay: 'Весь день', + noEvents: 'Событий нет', + more: (n) => `+${n}`, + monthsGenitive: [ + 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', + 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря', + ], + weekdaysLong: ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье'], }, carousel: { diff --git a/src/locales/types.ts b/src/locales/types.ts index ff29f70..9c56d5b 100644 --- a/src/locales/types.ts +++ b/src/locales/types.ts @@ -185,6 +185,12 @@ export interface UidLocale { calendar: { today: string + views: { day: string; week: string; month: string; year: string; agenda: string } + allDay: string + noEvents: string + more: (n: number) => string + monthsGenitive: string[] + weekdaysLong: string[] } carousel: { From cceaedbe5d8315528fb2de25b4277528cfe998e3 Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Sat, 31 Oct 2026 09:23:12 +0300 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20UidBoard=20readonly=20=E2=80=94=20c?= =?UTF-8?q?ards=20open=20but=20do=20not=20move?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .changeset/calendar-views.md | 2 ++ src/components/Board/UidBoard.css | 4 ++++ src/components/Board/UidBoard.spec.ts | 18 ++++++++++++++++++ src/components/Board/UidBoard.vue | 12 +++++++----- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/.changeset/calendar-views.md b/.changeset/calendar-views.md index 54fd67c..748cee8 100644 --- a/.changeset/calendar-views.md +++ b/.changeset/calendar-views.md @@ -3,3 +3,5 @@ --- UidCalendar: day, week, year and agenda views besides the month (`v-model:view`, `views` for the switcher, `v-model:anchor` for the shown date), events with a time (`time`, `endTime`) and across days (`endDate`), overlapping events side by side in the time grid, a `range` event with the visible days, and with `editable` drag-to-move, resize and `slot-click` on an empty slot (`event-move`). The month view stays as before when no other view is asked for. + +UidBoard: `readonly` — cards open on click and Enter but cannot be dragged or moved with Alt+arrows (for boards shown to people who may not change them). diff --git a/src/components/Board/UidBoard.css b/src/components/Board/UidBoard.css index 92a443f..67ecfef 100644 --- a/src/components/Board/UidBoard.css +++ b/src/components/Board/UidBoard.css @@ -135,3 +135,7 @@ font-size: var(--uid-font-size-xs, 11px); color: var(--uid-color-text-tertiary, var(--uid-color-text-secondary)); } + +.uid-board--readonly .uid-board__card { + cursor: pointer; +} diff --git a/src/components/Board/UidBoard.spec.ts b/src/components/Board/UidBoard.spec.ts index a28b38b..e51a313 100644 --- a/src/components/Board/UidBoard.spec.ts +++ b/src/components/Board/UidBoard.spec.ts @@ -97,4 +97,22 @@ describe('UidBoard', () => { expect(lanes[0]!.findAll('.uid-board__column')[0]!.findAll('.uid-board__card')).toHaveLength(1) expect(lanes[1]!.findAll('.uid-board__header')).toHaveLength(0) }) + + it('readonly: карточки не перетаскиваются и не двигаются клавишами, но открываются', async () => { + const wrapper = mount(UidBoard, { + props: { + readonly: true, + columns: [ + { key: 'a', title: 'A', cards: [{ key: 1, title: 'One' }] }, + { key: 'b', title: 'B', cards: [] }, + ], + }, + }) + const card = wrapper.find('.uid-board__card') + expect(card.attributes('draggable')).toBe('false') + await card.trigger('keydown', { key: 'ArrowRight', altKey: true }) + expect(wrapper.emitted('move')).toBeUndefined() + await card.trigger('click') + expect(wrapper.emitted('open')).toHaveLength(1) + }) }) diff --git a/src/components/Board/UidBoard.vue b/src/components/Board/UidBoard.vue index 812f9ab..ea4c12e 100644 --- a/src/components/Board/UidBoard.vue +++ b/src/components/Board/UidBoard.vue @@ -50,11 +50,13 @@ export interface UidBoardProps { lanes?: BoardLane[] /** Freeze the board while a move is being saved. */ busy?: boolean + readonly?: boolean } const props = withDefaults(defineProps(), { lanes: () => [], busy: false, + readonly: false, }) const emit = defineEmits<{ @@ -92,7 +94,7 @@ function allows(card: BoardCard, column: BoardColumn, from: BoardKey): boolean { } function onDragStart(event: DragEvent, card: BoardCard, column: BoardColumn): void { - if (props.busy) { + if (props.busy || props.readonly) { event.preventDefault() return } @@ -154,7 +156,7 @@ function onKeydown(event: KeyboardEvent, card: BoardCard, column: BoardColumn, l emit('open', card) return } - if (!event.altKey || (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') || props.busy) return + if (!event.altKey || (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') || props.busy || props.readonly) return event.preventDefault() const step = event.key === 'ArrowRight' ? 1 : -1 const index = props.columns.findIndex((c) => c.key === column.key) @@ -181,7 +183,7 @@ function isBlocked(column: BoardColumn): boolean {