frappe-ui 1.0.0-beta.55 → 1.0.0-beta.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/experimental/Calendar/Calendar.cy.ts +162 -0
  2. package/experimental/Calendar/Calendar.md +32 -0
  3. package/experimental/Calendar/Calendar.vue +64 -38
  4. package/experimental/Calendar/CalendarDaily.vue +11 -5
  5. package/experimental/Calendar/CalendarMonthEvent.vue +37 -12
  6. package/experimental/Calendar/CalendarMonthStack.vue +182 -0
  7. package/experimental/Calendar/CalendarMonthly.vue +219 -127
  8. package/experimental/Calendar/CalendarWeekDayEvent.vue +196 -127
  9. package/experimental/Calendar/CalendarWeekly.vue +82 -117
  10. package/experimental/Calendar/calendarUtils.ts +20 -9
  11. package/experimental/Calendar/composables/useCalendarData.ts +26 -57
  12. package/experimental/Calendar/composables/useStripScroll.ts +129 -0
  13. package/experimental/Calendar/eventSpan.spec.ts +337 -0
  14. package/experimental/Calendar/eventSpan.ts +259 -0
  15. package/experimental/Calendar/monthStrip.spec.ts +111 -0
  16. package/experimental/Calendar/monthStrip.ts +89 -0
  17. package/experimental/Calendar/stories/Examples.vue +32 -0
  18. package/experimental/Calendar/style.css +10 -3
  19. package/experimental/Calendar/types.ts +45 -0
  20. package/experimental/Calendar/useEventBase.ts +4 -7
  21. package/experimental/FloatingWindow/useFloatingWindow.ts +9 -4
  22. package/{src/components → experimental}/ThemeSwitcher/ThemePreview.vue +1 -1
  23. package/{src/components → experimental}/ThemeSwitcher/ThemeSwitcher.cy.ts +1 -1
  24. package/{src/components → experimental}/ThemeSwitcher/ThemeSwitcher.md +13 -0
  25. package/{src/components → experimental}/ThemeSwitcher/ThemeSwitcher.vue +2 -2
  26. package/{src/components → experimental}/ThemeSwitcher/stories/Branded.vue +1 -1
  27. package/{src/components → experimental}/ThemeSwitcher/stories/Default.vue +1 -1
  28. package/{src/components → experimental}/ThemeSwitcher/types.ts +1 -1
  29. package/experimental.ts +5 -0
  30. package/package.json +1 -1
  31. package/src/components/Button/Button.vue +1 -6
  32. package/src/components/Combobox/Combobox.api.md +1 -1
  33. package/src/components/Combobox/stories/MemberPicker.vue +2 -2
  34. package/src/components/Combobox/types.ts +2 -1
  35. package/src/components/DatePicker/index.ts +0 -1
  36. package/src/components/Dropdown/Dropdown.api.md +1 -1
  37. package/src/components/Dropdown/types.ts +2 -1
  38. package/src/components/HoverCard/HoverCard.api.md +3 -3
  39. package/src/components/HoverCard/types.ts +5 -4
  40. package/src/components/Menu/Menu.api.md +1 -1
  41. package/src/components/Menu/types.ts +2 -1
  42. package/src/components/MultiSelect/MultiSelect.api.md +1 -1
  43. package/src/components/MultiSelect/types.ts +2 -1
  44. package/src/components/Popover/Popover.api.md +1 -1
  45. package/src/components/Popover/types.ts +2 -1
  46. package/src/components/Select/Select.api.md +1 -1
  47. package/src/components/Select/types.ts +2 -1
  48. package/src/components/Sidebar/SidebarHeader.vue +9 -8
  49. package/src/components/TabButtons/TabButtons.api.md +2 -2
  50. package/src/components/TabButtons/TabButtons.cy.ts +23 -0
  51. package/src/components/TabButtons/TabButtons.md +25 -0
  52. package/src/components/TabButtons/TabButtons.vue +1 -2
  53. package/src/components/TabButtons/index.ts +0 -1
  54. package/src/components/TabButtons/types.ts +0 -3
  55. package/src/components/Toast/Toast.cy.ts +4 -4
  56. package/src/components/Toast/Toast.sanitize.test.ts +59 -1
  57. package/src/components/Toast/Toast.test.ts +31 -44
  58. package/src/components/Toast/toast.ts +33 -126
  59. package/src/index.ts +2 -3
  60. package/experimental/Calendar/ShowMoreCalendarEvent.vue +0 -35
  61. /package/{src/components → experimental}/ThemeSwitcher/ThemeSwitcher.api.md +0 -0
  62. /package/{src/components → experimental}/ThemeSwitcher/index.ts +0 -0
  63. /package/{src/components → experimental}/ThemeSwitcher/previews/PreviewWindow.vue +0 -0
  64. /package/{src/components → experimental}/ThemeSwitcher/stories/Menu.vue +0 -0
  65. /package/{src/components → experimental}/ThemeSwitcher/stories/Toggle.vue +0 -0
@@ -13,6 +13,16 @@ function monthYear(offsetDays = 0) {
13
13
 
14
14
  const today = monthYear()
15
15
 
16
+ /**
17
+ * A weekday (0 = Sunday) of the current week. Weeks run Sunday to Saturday,
18
+ * so a span over Monday to Wednesday sits inside one week row whatever day
19
+ * the suite runs on; the month grid pads with neighbouring months, so it
20
+ * shows those days whichever month they fall in.
21
+ */
22
+ function thisWeek(weekday: number) {
23
+ return monthYear(weekday - new Date().getDay())
24
+ }
25
+
16
26
  const events: CalendarEvent[] = [
17
27
  {
18
28
  id: 'EV-001',
@@ -40,6 +50,10 @@ const events: CalendarEvent[] = [
40
50
  ]
41
51
 
42
52
  describe('Calendar', () => {
53
+ // Below the `sm` breakpoint the Month view stacks days instead of drawing
54
+ // week rows; the desktop specs want the rows.
55
+ beforeEach(() => cy.viewport(1024, 768))
56
+
43
57
  // Behavior 1: renders with default props
44
58
  it('renders the month view with the default header', () => {
45
59
  cy.mount(Calendar, { props: { events: [] } })
@@ -75,6 +89,134 @@ describe('Calendar', () => {
75
89
  cy.contains('Design review').should('not.exist')
76
90
  })
77
91
 
92
+ it('draws a multi-day event as one bar in the month view', () => {
93
+ cy.mount(Calendar, {
94
+ props: {
95
+ events: [
96
+ {
97
+ id: 'EV-STAY',
98
+ title: 'Offsite',
99
+ fromDate: thisWeek(1),
100
+ toDate: thisWeek(3),
101
+ isFullDay: true,
102
+ color: 'cyan',
103
+ },
104
+ ],
105
+ },
106
+ })
107
+
108
+ // One bar across the stay's three days, never a copy per day.
109
+ cy.get('.event')
110
+ .filter(':contains("Offsite")')
111
+ .should('have.length', 1)
112
+ .then(($bar) => {
113
+ const cell = $bar.closest('[data-week-row]')
114
+ const cellWidth = cell.width()! / 7
115
+ expect($bar.width()!).to.be.greaterThan(cellWidth * 1.5)
116
+ })
117
+ })
118
+
119
+ it('sizes a month row to its day and shows every event in it', () => {
120
+ const titles = [
121
+ 'Standup',
122
+ 'Design review',
123
+ 'Interview',
124
+ 'Team lunch',
125
+ 'Retro',
126
+ ]
127
+ cy.mount(Calendar, {
128
+ props: {
129
+ events: titles.map((title, i) => ({
130
+ id: `EV-${i}`,
131
+ title,
132
+ fromDate: today,
133
+ toDate: today,
134
+ fromTime: `${9 + i}:00`,
135
+ toTime: `${10 + i}:00`,
136
+ })),
137
+ },
138
+ })
139
+
140
+ // Five events on one day: all five rendered, nothing folded behind a
141
+ // count.
142
+ for (const title of titles)
143
+ cy.contains('.event', title).should('be.visible')
144
+ cy.contains('more').should('not.exist')
145
+ })
146
+
147
+ it('names the month on its first day', () => {
148
+ // Pinned mid-month: today's own number is a bare pill, so on the 1st the
149
+ // label would not be there to find.
150
+ cy.clock(new Date(2026, 7, 15), ['Date'])
151
+ cy.mount(Calendar, { props: { events: [] } })
152
+
153
+ cy.get('[data-strip-date]').first().contains('Aug 1').should('exist')
154
+ })
155
+
156
+ it('stacks the days on a narrow screen', () => {
157
+ cy.viewport(390, 800)
158
+ cy.mount(Calendar, {
159
+ props: {
160
+ events: [
161
+ {
162
+ id: 'EV-STAY',
163
+ title: 'Offsite',
164
+ fromDate: thisWeek(1),
165
+ toDate: thisWeek(3),
166
+ isFullDay: true,
167
+ },
168
+ ],
169
+ },
170
+ })
171
+
172
+ // No week-row grid; a row per day, with a stay saying which day it is on.
173
+ cy.get('[data-week-row]').should('not.exist')
174
+ cy.contains('.event', 'Offsite').should('have.length.at.least', 1)
175
+ cy.contains('Day 1 of 3').should('exist')
176
+ })
177
+
178
+ it('puts a multi-day event in the all-day row and splits an overnight one', () => {
179
+ cy.mount(Calendar, {
180
+ props: {
181
+ events: [
182
+ {
183
+ id: 'EV-STAY',
184
+ title: 'Offsite',
185
+ fromDate: thisWeek(1),
186
+ toDate: thisWeek(2),
187
+ isFullDay: true,
188
+ color: 'cyan',
189
+ },
190
+ {
191
+ id: 'EV-NIGHT',
192
+ title: 'Release night',
193
+ fromDate: thisWeek(1),
194
+ toDate: thisWeek(2),
195
+ fromTime: '22:00',
196
+ toTime: '02:00',
197
+ color: 'violet',
198
+ },
199
+ ],
200
+ config: { defaultMode: 'Week' },
201
+ },
202
+ })
203
+
204
+ // The all-day row holds the stay as a bar spanning its days.
205
+ cy.get('[data-day-columns]')
206
+ .first()
207
+ .within(() => {
208
+ cy.contains('.event', 'Offsite').should('exist')
209
+ })
210
+ // The overnight event shows once per day in the time grid, each piece
211
+ // still labelled with the whole event's times.
212
+ cy.get('[data-time-grid] .event')
213
+ .filter(':contains("Release night")')
214
+ .should('have.length', 2)
215
+ .each(($piece) => {
216
+ expect($piece.text()).to.contain('10 pm - 2 am')
217
+ })
218
+ })
219
+
78
220
  it('emits rangeChange with the visible range on mount', () => {
79
221
  cy.mount(Calendar, {
80
222
  props: { events: [], onRangeChange: cy.spy().as('onRangeChange') },
@@ -122,6 +264,26 @@ describe('Calendar', () => {
122
264
  cy.contains('All day').should('not.exist')
123
265
  })
124
266
 
267
+ // Anything layered over the calendar owns the keyboard. The shortcuts are bare
268
+ // letters, so without this they reached straight through an open dialog and
269
+ // switched the view behind it.
270
+ it('ignores shortcuts while an overlay is open', () => {
271
+ cy.mount(Calendar, { props: { events: [] } })
272
+
273
+ // The month/year button is the header's first control; its picker is a dialog.
274
+ cy.get('button').first().click()
275
+ cy.get('[role=dialog]').should('exist')
276
+
277
+ cy.get('body').type('w')
278
+ cy.contains('All day').should('not.exist')
279
+
280
+ // Closed again, the same key does what it always did.
281
+ cy.get('body').type('{esc}')
282
+ cy.get('[role=dialog]').should('not.exist')
283
+ cy.get('body').type('w')
284
+ cy.contains('All day').should('exist')
285
+ })
286
+
125
287
  // Behavior 5: every documented slot renders
126
288
  it('renders the #header slot with its props', () => {
127
289
  cy.mount(Calendar, {
@@ -45,6 +45,16 @@ Each entry in `events` is a `CalendarEvent`:
45
45
  }
46
46
  ```
47
47
 
48
+ An event runs from `fromDate fromTime` to `toDate toTime`, dates inclusive.
49
+ One whose `toDate` is later than its `fromDate` spans those days: the Month
50
+ view draws it as a single bar across them, and the Week view puts it in the
51
+ all-day row. A timed event that crosses midnight but is shorter than a day
52
+ (an evening running late) stays in the time grid, cut at midnight into a
53
+ piece per day. A timed event ending at `00:00` stops as that day begins, so
54
+ it does not occupy it. Dragging a spanning event moves both ends together.
55
+
56
+ `isFullDay` events ignore their times and cover `fromDate`..`toDate` whole.
57
+
48
58
  The calendar keeps an internal copy of `events` and refreshes it when the
49
59
  prop changes. Edits made inside the calendar (create, drag, resize, delete)
50
60
  mutate the copy and come back through the `create`, `update`, and `delete`
@@ -54,6 +64,25 @@ emits — persist them and refresh your source of truth from there.
54
64
  `cyan`, `blue`, `orange`, `green`) with the CSS variables used per state, for
55
65
  building matching UI such as a color picker.
56
66
 
67
+ ## Month view
68
+
69
+ The Month view is a strip of week rows covering the month in view, and it
70
+ scrolls when the rows outgrow the calendar's height. The arrows, Today, and
71
+ the month picker move the month and scroll the strip to their date.
72
+
73
+ Each row is as tall as its busiest day needs. Multi-day events run as bars
74
+ across the top of the row; single-day events sit beneath them in their
75
+ cells with the title wrapping to a second line, so every event is shown —
76
+ there is no "n more".
77
+
78
+ Below the `sm` breakpoint the days stack instead: a row per day, a heading
79
+ where each month begins, and a week strip above to keep your place. Clicking a date number in either layout
80
+ opens that day in the Day view.
81
+
82
+ `rangeChange` reports the strip's full extent for the Month view — the
83
+ padding days of the first and last weeks included — so a data source that
84
+ fetches by range has events for every cell.
85
+
57
86
  ## Config
58
87
 
59
88
  The `config` prop takes a partial `CalendarConfig`; unset keys use these
@@ -83,6 +112,9 @@ With `enableShortcuts` on: `m` / `w` / `d` switch views, `t` jumps to today,
83
112
  `←` / `→` navigate, and `Delete` removes the event whose popover is open
84
113
  (edit mode only).
85
114
 
115
+ They stay out of the way of whatever is on top: nothing fires while a field has
116
+ focus, or while a dialog, popover, menu or select is open anywhere on the page.
117
+
86
118
  ## Click handling
87
119
 
88
120
  By default, a single click on an event opens its detail popover and a double
@@ -1,5 +1,7 @@
1
1
  <template>
2
- <div class="flex h-full flex-col overflow-hidden">
2
+ <!-- No overflow-hidden here: each view scrolls itself, and clipping at the
3
+ root only cut the focus outline off the header's buttons. -->
4
+ <div class="flex h-full flex-col">
3
5
  <slot
4
6
  name="header"
5
7
  v-bind="{
@@ -67,9 +69,10 @@
67
69
  v-if="activeView === 'Month'"
68
70
  :events="events"
69
71
  :currentMonth="currentMonth"
70
- :currentMonthDates="currentMonthDates"
72
+ :currentYear="currentYear"
73
+ :currentDate="selectedDay"
74
+ :jumpDate="currentDate"
71
75
  :config="overrideConfig"
72
- @setCurrentDate="(d) => updateCurrentDate(d)"
73
76
  >
74
77
  <template #event-popover-content="slotProps">
75
78
  <slot name="event-popover-content" v-bind="slotProps" />
@@ -142,6 +145,8 @@ import CalendarDaily from './CalendarDaily.vue'
142
145
  import NewEventModal from './NewEventModal.vue'
143
146
  import useEventModal from './composables/useEventModal'
144
147
  import { isAnyPopoverOpen } from './useEventBase'
148
+ import { stripPlacement } from './eventSpan'
149
+ import { stripRange } from './monthStrip'
145
150
  import {
146
151
  ACTIVE_VIEW_KEY,
147
152
  CALENDAR_ACTIONS_KEY,
@@ -227,6 +232,27 @@ function syncSelectedMonth(year: number, month: number) {
227
232
  }
228
233
  }
229
234
 
235
+ // Anything layered over the calendar owns the keyboard while it is up — a modal,
236
+ // a dropdown, an event's own popover, the header's month picker. The shortcuts are
237
+ // bare letters, so a press meant for the thing on top switched the view behind it,
238
+ // and an arrow key moved the month under an open picker.
239
+ //
240
+ // Asked of the document rather than of the event's ancestors: an overlay is
241
+ // portalled to <body>, a sibling of the calendar rather than a descendant, and a
242
+ // press with nothing focused reports <body> itself as the target — `closest()`
243
+ // from there would see neither.
244
+ //
245
+ // By role, which is what tells an overlay apart from the rest of the page: reka
246
+ // gives dialogs and popovers `dialog`, menus `menu`, selects `listbox`, and each
247
+ // is in the DOM only while open. Tooltips are `tooltip` and so are left out — one
248
+ // showing under the pointer is not something the keyboard is talking to.
249
+ const OVERLAY_SELECTOR =
250
+ '[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]'
251
+
252
+ function isOverlayOpen() {
253
+ return !!document.querySelector(OVERLAY_SELECTOR)
254
+ }
255
+
230
256
  // shortcuts for changing the active view and navigating through the calendar
231
257
  onMounted(() => {
232
258
  if (!overrideConfig.enableShortcuts) return
@@ -236,6 +262,8 @@ onUnmounted(() => {
236
262
  window.removeEventListener('keydown', handleShortcuts)
237
263
  })
238
264
  function handleShortcuts(e: KeyboardEvent) {
265
+ if (isOverlayOpen()) return
266
+
239
267
  const target = e.target as HTMLElement | null
240
268
  if (
241
269
  target?.tagName === 'INPUT' ||
@@ -274,28 +302,31 @@ watch(activeView, (value) => {
274
302
  }
275
303
  })
276
304
 
277
- const parseEvents = computed(() => {
305
+ /**
306
+ * A fresh internal copy of the events prop. A function rather than a computed
307
+ * on purpose: the views edit the copy in place (a drag shifts its dates), and
308
+ * `reloadEvents` must hand back the prop's values, not the edited objects a
309
+ * cached computed would return again.
310
+ */
311
+ function parseEvents(): CalendarEvent[] {
278
312
  return (
279
313
  props.events?.map((event) => {
280
314
  const { fromDate, toDate, fromTime, toTime, ...rest } = event
281
- const date = fromDate
282
- const fromDateTime = fromDate + ' ' + fromTime
283
- const toDateTime = toDate + ' ' + toTime
284
-
315
+ const timed = !!(fromTime && toTime)
285
316
  return {
286
317
  ...rest,
287
- date,
288
- fromDateTime,
289
- toDateTime,
318
+ date: fromDate,
319
+ fromDateTime: fromDate + ' ' + fromTime,
320
+ toDateTime: toDate + ' ' + toTime,
290
321
  fromDate,
291
322
  toDate,
292
- fromTime,
293
- toTime,
323
+ fromTime: timed ? handleSeconds(fromTime) : fromTime,
324
+ toTime: timed ? handleSeconds(toTime) : toTime,
294
325
  }
295
326
  }) || []
296
327
  )
297
- })
298
- const events = ref<CalendarEvent[]>(parseEvents.value)
328
+ }
329
+ const events = ref<CalendarEvent[]>(parseEvents())
299
330
 
300
331
  watch(
301
332
  () => props.events,
@@ -304,16 +335,9 @@ watch(
304
335
  )
305
336
 
306
337
  function reloadEvents() {
307
- events.value = parseEvents.value
338
+ events.value = parseEvents()
308
339
  }
309
340
 
310
- events.value.forEach((event) => {
311
- if (!event.fromTime || !event.toTime) return
312
-
313
- event.fromTime = handleSeconds(event.fromTime)
314
- event.toTime = handleSeconds(event.toTime)
315
- })
316
-
317
341
  const { showEventModal, newEvent, openNewEventModal } = useEventModal()
318
342
 
319
343
  provide(CALENDAR_ACTIONS_KEY, {
@@ -322,6 +346,7 @@ provide(CALENDAR_ACTIONS_KEY, {
322
346
  deleteEvent,
323
347
  handleCellClick,
324
348
  updateActiveView,
349
+ setCalendarDate,
325
350
  props,
326
351
  })
327
352
 
@@ -334,6 +359,8 @@ function createNewEvent(event: CalendarEvent) {
334
359
  }
335
360
 
336
361
  function updateEventState(event: CalendarEvent) {
362
+ // A view may hand back one day's piece of the event; store the whole one.
363
+ event = stripPlacement(event)
337
364
  const eventID = event.id
338
365
  let eventIndex = events.value.findIndex((e) => e.id === eventID)
339
366
  event.fromDateTime = event.fromDate + ' ' + event.fromTime
@@ -466,12 +493,6 @@ watch(currentDay, (newVal) => {
466
493
  setCalendarDate(target)
467
494
  })
468
495
 
469
- function updateCurrentDate(d: Date) {
470
- activeView.value = 'Day'
471
- date.value = findIndexOfDate(d)
472
- week.value = findCurrentWeek(d)
473
- }
474
-
475
496
  function increment() {
476
497
  incrementClickEvents[activeView.value]()
477
498
  syncSelectedMonth(currentYear.value, currentMonth.value)
@@ -488,8 +509,17 @@ const incrementClickEvents: Record<CalendarMode, () => void> = {
488
509
  Day: incrementDay,
489
510
  }
490
511
 
512
+ // decrementMonth lands on the month's last day, which is right for stepping
513
+ // back a day across a month edge but scrolls the Month strip to its bottom;
514
+ // the Month view's own arrow lands on the first day, as the other arrow does.
515
+ function decrementMonthView() {
516
+ decrementMonth()
517
+ date.value = findFirstDateOfMonth(currentMonth.value, currentYear.value)
518
+ week.value = findCurrentWeek(currentMonthDates.value[date.value])
519
+ }
520
+
491
521
  const decrementClickEvents: Record<CalendarMode, () => void> = {
492
- Month: decrementMonth,
522
+ Month: decrementMonthView,
493
523
  Week: decrementWeek,
494
524
  Day: decrementDay,
495
525
  }
@@ -712,15 +742,11 @@ function getVisibleRange() {
712
742
  }
713
743
  }
714
744
 
715
- const start = dayjs(
716
- new Date(currentYear.value, currentMonth.value, 1),
717
- ).startOf('day')
718
- const end = dayjs(
719
- new Date(currentYear.value, currentMonth.value + 1, 0),
720
- ).endOf('day')
745
+ // The Month strip's first and last weeks reach into the neighbouring months.
746
+ const range = stripRange(currentMonth.value, currentYear.value)
721
747
  return {
722
- startDate: start.format('YYYY-MM-DD'),
723
- endDate: end.format('YYYY-MM-DD'),
748
+ startDate: dayjs(range.start).format('YYYY-MM-DD'),
749
+ endDate: dayjs(range.end).format('YYYY-MM-DD'),
724
750
  }
725
751
  }
726
752
 
@@ -126,6 +126,7 @@ import {
126
126
  twentyFourHoursFormat,
127
127
  } from './calendarUtils'
128
128
  import useCalendarData from './composables/useCalendarData'
129
+ import { eventDays } from './eventSpan'
129
130
  import CalendarWeekDayEvent from './CalendarWeekDayEvent.vue'
130
131
  import {
131
132
  CALENDAR_ACTIONS_KEY,
@@ -141,8 +142,8 @@ const props = defineProps<{
141
142
  const timedEvents = computed(
142
143
  () => useCalendarData(props.events).timedEvents.value,
143
144
  )
144
- const fullDayEvents = computed(
145
- () => useCalendarData(props.events).fullDayEvents.value,
145
+ const allDayEvents = computed(
146
+ () => useCalendarData(props.events).allDayEvents.value,
146
147
  )
147
148
  const gridRef = ref<HTMLElement | null>(null)
148
149
  const hourHeight = props.config.hourHeight
@@ -150,9 +151,14 @@ const minuteHeight = hourHeight / 60
150
151
 
151
152
  const showCollapsable = ref(false)
152
153
  const isCollapsed = ref(true)
153
- const dayFullDayEvents = computed(
154
- () => fullDayEvents.value?.[parseDate(props.currentDate)] || [],
155
- )
154
+ // Every all-day-row event covering this day, a multi-day one included.
155
+ const dayFullDayEvents = computed(() => {
156
+ const day = parseDate(props.currentDate)
157
+ return allDayEvents.value.filter((event) => {
158
+ const { start, end } = eventDays(event)
159
+ return start <= day && day <= end
160
+ })
161
+ })
156
162
 
157
163
  function updateFullDayEventsState() {
158
164
  // Show collapsible if more than 4 events
@@ -16,9 +16,12 @@
16
16
  <template #trigger>
17
17
  <div
18
18
  v-bind="$attrs"
19
- class="event flex gap-1.5 min-h-6 mx-px rounded-4 p-[5px] transition-all duration-75 w-full overflow-hidden"
19
+ class="event flex gap-1.5 min-h-6 rounded-4 p-[5px] transition-all duration-75 overflow-hidden"
20
20
  :class="{
21
21
  active: activeEvent == (props.event?.id || props.event?.name),
22
+ 'rounded-l-none': bar && !bar.isStart,
23
+ 'rounded-r-none': bar && !bar.isEnd,
24
+ 'event-draft': !!props.event.isDraft,
22
25
  }"
23
26
  :style="eventBgStyle"
24
27
  @click.stop="
@@ -31,22 +34,32 @@
31
34
  @dblclick.prevent="handleEventEdit($event)"
32
35
  >
33
36
  <div
34
- v-if="props.event.fromTime"
37
+ v-if="props.event.fromTime && !props.event.isDraft"
35
38
  class="event-border w-[2px] rounded-4 shrink-0"
36
- :style="eventBorderStyle"
37
39
  />
38
40
  <div
39
- class="relative flex h-full select-none items-start gap-2 overflow-hidden"
41
+ class="relative flex h-full min-w-0 select-none items-start gap-2 overflow-hidden"
40
42
  >
41
43
  <div v-if="config.showIcon && eventIcon">
42
44
  <component :is="eventIcon" class="h-4 w-4 text-ink-gray-8" />
43
45
  </div>
44
- <p
45
- class="text-sm-medium truncate text-ink-gray-8"
46
- :class="{ italic: !props.event.title }"
47
- >
48
- {{ props.event.title || '[No title]' }}
49
- </p>
46
+ <div class="min-w-0">
47
+ <p
48
+ class="event-title text-sm-medium"
49
+ :class="[
50
+ wrap ? 'line-clamp-2 break-words' : 'truncate',
51
+ props.event.isDeclined
52
+ ? 'line-through text-ink-gray-5'
53
+ : 'text-ink-gray-8',
54
+ { italic: !props.event.title },
55
+ ]"
56
+ >
57
+ {{ props.event.title || '[No title]' }}
58
+ </p>
59
+ <p v-if="subtitle" class="event-subtitle mt-0.5 truncate text-xs">
60
+ {{ subtitle }}
61
+ </p>
62
+ </div>
50
63
  </div>
51
64
  </div>
52
65
  </template>
@@ -92,13 +105,26 @@ import NewEventModal from './NewEventModal.vue'
92
105
  import Popover from '#components/Popover/Popover.vue'
93
106
  import { useEventBase } from './useEventBase'
94
107
  import { computed, ref } from 'vue'
95
- import type { CalendarEvent } from './types'
108
+ import type { CalendarEvent, CalendarRowBar } from './types'
96
109
 
97
110
  const isPopoverOpen = ref(false)
98
111
 
99
112
  const props = defineProps<{
100
113
  event: CalendarEvent
101
114
  date: Date
115
+ /**
116
+ * The bar this pill draws in a week row. A side the event runs past is
117
+ * squared off, so a stay that began last week reads as continuing.
118
+ */
119
+ bar?: CalendarRowBar
120
+ /**
121
+ * Lets the title run to a second line instead of cutting it off. The
122
+ * Month strip sizes its rows to their content, so a pill there can afford
123
+ * to show its whole title.
124
+ */
125
+ wrap?: boolean
126
+ /** A second, quieter line under the title — a time, or "Day 2 of 3". */
127
+ subtitle?: string
102
128
  }>()
103
129
 
104
130
  defineOptions({ inheritAttrs: false })
@@ -111,7 +137,6 @@ const {
111
137
  eventIcons,
112
138
  showEventModal,
113
139
  eventBgStyle,
114
- eventBorderStyle,
115
140
  handleEventClick,
116
141
  handleEventEdit,
117
142
  handleEventDelete,