terminoir 0.5.0 → 0.5.1

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.
@@ -3,8 +3,8 @@
3
3
  import {
4
4
  addDays,
5
5
  calendarRange,
6
- dayLayout,
7
6
  eventsOn,
7
+ layoutDay,
8
8
  monthGrid,
9
9
  sameDay,
10
10
  startOfWeek,
@@ -52,11 +52,12 @@
52
52
  }
53
53
  </script>
54
54
 
55
- {#snippet chip(event: CalendarEvent, showTime: boolean)}
55
+ {#snippet chip(event: CalendarEvent, showTime: boolean, compact = false)}
56
56
  <button
57
57
  type="button"
58
58
  class="terminoir-cal__event terminoir-focusable"
59
59
  class:terminoir-cal__event--muted={event.muted}
60
+ class:terminoir-cal__event--compact={compact}
60
61
  style:--event-color={event.color}
61
62
  title={event.title}
62
63
  disabled={!onEventClick}
@@ -91,10 +92,15 @@
91
92
  </div>
92
93
  {#each weekDays as day (day.getTime())}
93
94
  <div class="terminoir-cal__day-col" class:terminoir-cal__day-col--today={sameDay(day, today)}>
94
- {#each eventsOn(events, day).filter((e) => !e.allDay) as event (event.id)}
95
- {@const layout = dayLayout(event)}
96
- <div class="terminoir-cal__timed" style:top={`${layout.top}%`} style:height={`${layout.height}%`}>
97
- {@render chip(event, true)}
95
+ {#each layoutDay(eventsOn(events, day).filter((e) => !e.allDay)) as placed (placed.event.id)}
96
+ <div
97
+ class="terminoir-cal__timed"
98
+ style:top={`${placed.top}%`}
99
+ style:height={`${placed.height}%`}
100
+ style:left={`calc(${(placed.lane / placed.lanes) * 100}% + 2px)`}
101
+ style:width={`calc(${100 / placed.lanes}% - 4px)`}
102
+ >
103
+ {@render chip(placed.event, true, placed.compact)}
98
104
  </div>
99
105
  {/each}
100
106
  </div>
@@ -179,8 +185,17 @@
179
185
  background-size: 100% var(--cal-hour);
180
186
  }
181
187
  .terminoir-cal__day-col--today { background-color: color-mix(in srgb, var(--primary) 4%, transparent); }
182
- .terminoir-cal__timed { position: absolute; left: 2px; right: 2px; display: flex; }
188
+ .terminoir-cal__timed { position: absolute; display: flex; }
183
189
  .terminoir-cal__timed .terminoir-cal__event { align-items: flex-start; height: 100%; flex-direction: column; gap: 0; }
190
+ .terminoir-cal__timed .terminoir-cal__event--compact {
191
+ flex-direction: row-reverse;
192
+ justify-content: flex-end;
193
+ align-items: center;
194
+ gap: var(--space-1);
195
+ padding-block: 0;
196
+ font-size: 0.6875rem;
197
+ line-height: 1;
198
+ }
184
199
  .terminoir-cal__event {
185
200
  --event-color: var(--primary);
186
201
  display: flex;
@@ -28,19 +28,27 @@
28
28
  }
29
29
  }
30
30
 
31
- function onKeydown(event: KeyboardEvent) {
32
- if (event.key === 'Escape') {
33
- open = false;
34
- onclose?.();
35
- }
36
- }
31
+ let dialogEl: HTMLDivElement | undefined = $state();
37
32
 
33
+ /** Escape closes from anywhere, not only when focus is already inside the dialog. */
38
34
  $effect(() => {
39
35
  if (!open || typeof document === 'undefined') return;
40
36
  const prev = document.body.style.overflow;
41
37
  document.body.style.overflow = 'hidden';
38
+ const onKey = (event: KeyboardEvent) => {
39
+ if (event.key === 'Escape') {
40
+ open = false;
41
+ onclose?.();
42
+ }
43
+ };
44
+ document.addEventListener('keydown', onKey);
45
+ queueMicrotask(() => {
46
+ const first = dialogEl?.querySelector<HTMLElement>('input, select, textarea, button, [tabindex]:not([tabindex="-1"])');
47
+ (first ?? dialogEl)?.focus();
48
+ });
42
49
  return () => {
43
50
  document.body.style.overflow = prev;
51
+ document.removeEventListener('keydown', onKey);
44
52
  };
45
53
  });
46
54
  </script>
@@ -50,9 +58,9 @@
50
58
  class="terminoir-dialog-backdrop"
51
59
  role="presentation"
52
60
  onclick={onBackdropClick}
53
- onkeydown={onKeydown}
54
61
  >
55
62
  <div
63
+ bind:this={dialogEl}
56
64
  class={cn('terminoir-dialog glass-modal terminoir-focusable', className)}
57
65
  role="dialog"
58
66
  aria-modal="true"
@@ -26,10 +26,23 @@ export declare function calendarRange(anchor: Date, mode: CalendarMode): {
26
26
  export declare function shiftCalendar(anchor: Date, mode: CalendarMode, delta: number): Date;
27
27
  /** Six Monday-start weeks covering the month of `monthStart`. */
28
28
  export declare function monthGrid(monthStart: Date): Date[];
29
- /** Events starting on `day`, all-day first, then by start time. */
29
+ /** Events showing on `day` (multi-day all-day events on each day), all-day first, then by start time. */
30
30
  export declare function eventsOn(events: CalendarEvent[], day: Date): CalendarEvent[];
31
31
  /** Vertical position of a timed event in a day column, as percentages of the day. */
32
32
  export declare function dayLayout(event: CalendarEvent): {
33
33
  top: number;
34
34
  height: number;
35
35
  };
36
+ export type PlacedEvent = {
37
+ event: CalendarEvent;
38
+ /** Percent of the day from the top. */
39
+ top: number;
40
+ height: number;
41
+ /** Side-by-side column within its overlap group, and how many columns the group has. */
42
+ lane: number;
43
+ lanes: number;
44
+ /** Short enough to render as one line. */
45
+ compact: boolean;
46
+ };
47
+ /** Lay out a day's timed events so overlapping ones sit side by side instead of on top of each other. */
48
+ export declare function layoutDay(events: CalendarEvent[]): PlacedEvent[];
@@ -46,10 +46,18 @@ export function monthGrid(monthStart) {
46
46
  const first = startOfWeek(monthStart);
47
47
  return Array.from({ length: MONTH_GRID_DAYS }, (_, i) => addDays(first, i));
48
48
  }
49
- /** Events starting on `day`, all-day first, then by start time. */
49
+ /** Whether `event` shows on `day`: timed events on their start day, all-day events on every day they span. */
50
+ function occursOn(event, day) {
51
+ if (!event.allDay)
52
+ return sameDay(event.start, day);
53
+ const dayStart = startOfDay(day).getTime();
54
+ const end = Math.max(event.end.getTime(), addDays(startOfDay(event.start), 1).getTime());
55
+ return startOfDay(event.start).getTime() <= dayStart && dayStart < end;
56
+ }
57
+ /** Events showing on `day` (multi-day all-day events on each day), all-day first, then by start time. */
50
58
  export function eventsOn(events, day) {
51
59
  return events
52
- .filter((e) => sameDay(e.start, day))
60
+ .filter((e) => occursOn(e, day))
53
61
  .sort((a, b) => Number(b.allDay) - Number(a.allDay) || a.start.getTime() - b.start.getTime());
54
62
  }
55
63
  /** Vertical position of a timed event in a day column, as percentages of the day. */
@@ -59,3 +67,39 @@ export function dayLayout(event) {
59
67
  const top = (minutes / MINUTES_PER_DAY) * 100;
60
68
  return { top, height: Math.min((duration / MINUTES_PER_DAY) * 100, 100 - top) };
61
69
  }
70
+ const COMPACT_MAX_MINUTES = 30;
71
+ /** Lay out a day's timed events so overlapping ones sit side by side instead of on top of each other. */
72
+ export function layoutDay(events) {
73
+ const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime() || b.end.getTime() - a.end.getTime());
74
+ const placed = [];
75
+ let group = [];
76
+ let laneEnds = [];
77
+ let groupEnd = -Infinity;
78
+ const closeGroup = () => {
79
+ for (const item of group)
80
+ item.lanes = laneEnds.length;
81
+ group = [];
82
+ laneEnds = [];
83
+ };
84
+ for (const event of sorted) {
85
+ const start = event.start.getTime();
86
+ const end = Math.max(event.end.getTime(), start + MIN_EVENT_MINUTES * 60000);
87
+ if (start >= groupEnd)
88
+ closeGroup();
89
+ let lane = laneEnds.findIndex((laneEnd) => laneEnd <= start);
90
+ if (lane === -1) {
91
+ lane = laneEnds.length;
92
+ laneEnds.push(end);
93
+ }
94
+ else {
95
+ laneEnds[lane] = end;
96
+ }
97
+ groupEnd = Math.max(groupEnd, end);
98
+ const minutes = (event.end.getTime() - start) / 60000;
99
+ const item = { event, ...dayLayout(event), lane, lanes: 1, compact: minutes <= COMPACT_MAX_MINUTES };
100
+ group.push(item);
101
+ placed.push(item);
102
+ }
103
+ closeGroup();
104
+ return placed;
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminoir",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Terminoir \u2014 Liquid Glass design tokens and Svelte 5 UI components.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",