Moving away from Azure Devops #1
11 changed files with 850 additions and 4 deletions
408
docs/V2-ARCHITECTURE.md
Normal file
408
docs/V2-ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,408 @@
|
||||||
|
# Calendar V2 Architecture
|
||||||
|
|
||||||
|
## Oversigt
|
||||||
|
|
||||||
|
Calendar V2 er bygget med en event-driven arkitektur og et fleksibelt grouping-system der tillader forskellige kalendervisninger (dag, uge, ressource, team).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event System
|
||||||
|
|
||||||
|
### CoreEvents Katalog
|
||||||
|
|
||||||
|
Alle events er defineret i `src/v2/constants/CoreEvents.ts`.
|
||||||
|
|
||||||
|
#### Drag-Drop Events
|
||||||
|
|
||||||
|
| Event | Payload | Emitter | Subscribers |
|
||||||
|
|-------|---------|---------|-------------|
|
||||||
|
| `event:drag-start` | `IDragStartPayload` | DragDropManager | EdgeScrollManager |
|
||||||
|
| `event:drag-move` | `IDragMovePayload` | DragDropManager | EventRenderer |
|
||||||
|
| `event:drag-end` | `IDragEndPayload` | DragDropManager | EdgeScrollManager |
|
||||||
|
| `event:drag-cancel` | `IDragCancelPayload` | DragDropManager | EdgeScrollManager |
|
||||||
|
| `event:drag-column-change` | `IDragColumnChangePayload` | DragDropManager | EventRenderer |
|
||||||
|
|
||||||
|
#### Resize Events
|
||||||
|
|
||||||
|
| Event | Payload | Emitter | Subscribers |
|
||||||
|
|-------|---------|---------|-------------|
|
||||||
|
| `event:resize-start` | `IResizeStartPayload` | ResizeManager | - |
|
||||||
|
| `event:resize-end` | `IResizeEndPayload` | ResizeManager | - |
|
||||||
|
|
||||||
|
#### Edge Scroll Events
|
||||||
|
|
||||||
|
| Event | Payload | Emitter | Subscribers |
|
||||||
|
|-------|---------|---------|-------------|
|
||||||
|
| `edge-scroll:tick` | `{ scrollDelta: number }` | EdgeScrollManager | DragDropManager |
|
||||||
|
| `edge-scroll:started` | `{}` | EdgeScrollManager | - |
|
||||||
|
| `edge-scroll:stopped` | `{}` | EdgeScrollManager | - |
|
||||||
|
|
||||||
|
#### Lifecycle Events
|
||||||
|
|
||||||
|
| Event | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `core:initialized` | DI container klar |
|
||||||
|
| `core:ready` | Kalender fuldt initialiseret |
|
||||||
|
| `core:destroyed` | Cleanup færdig |
|
||||||
|
|
||||||
|
#### Data Events
|
||||||
|
|
||||||
|
| Event | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `data:loading` | Data fetch startet |
|
||||||
|
| `data:loaded` | Data fetch færdig |
|
||||||
|
| `data:error` | Data fetch fejlet |
|
||||||
|
| `entity:saved` | Entity gemt i IndexedDB |
|
||||||
|
| `entity:deleted` | Entity slettet fra IndexedDB |
|
||||||
|
|
||||||
|
#### View Events
|
||||||
|
|
||||||
|
| Event | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `view:changed` | View/grouping ændret |
|
||||||
|
| `view:rendered` | View rendering færdig |
|
||||||
|
| `events:rendered` | Events renderet til DOM |
|
||||||
|
| `grid:rendered` | Time grid renderet |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event Flows
|
||||||
|
|
||||||
|
### Drag-Drop Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
pointerdown på event
|
||||||
|
└── DragDropManager.handlePointerDown()
|
||||||
|
└── Gem mouseDownPosition, capture pointer
|
||||||
|
|
||||||
|
pointermove (>5px)
|
||||||
|
└── DragDropManager.initializeDrag()
|
||||||
|
├── Opret ghost clone (opacity 0.3)
|
||||||
|
├── Marker original med .dragging
|
||||||
|
├── EMIT: event:drag-start
|
||||||
|
│ └── EdgeScrollManager starter scrollTick loop
|
||||||
|
└── Start animateDrag() RAF loop
|
||||||
|
|
||||||
|
pointermove (under drag)
|
||||||
|
├── DragDropManager.updateDragTarget()
|
||||||
|
│ ├── Detect kolonneændring
|
||||||
|
│ │ └── EMIT: event:drag-column-change
|
||||||
|
│ │ └── EventRenderer flytter element til ny kolonne
|
||||||
|
│ └── Beregn targetY for smooth interpolation
|
||||||
|
│
|
||||||
|
└── DragDropManager.animateDrag() (RAF)
|
||||||
|
├── Interpoler currentY mod targetY (factor 0.3)
|
||||||
|
├── Opdater element.style.top
|
||||||
|
└── EMIT: event:drag-move
|
||||||
|
└── EventRenderer.updateDragTimestamp()
|
||||||
|
└── Beregn snapped tid og opdater visning
|
||||||
|
|
||||||
|
EdgeScrollManager.scrollTick() (parallel RAF)
|
||||||
|
├── Beregn velocity baseret på museafstand til kanter
|
||||||
|
│ - Inner zone (0-50px): 640 px/sek
|
||||||
|
│ - Outer zone (50-100px): 140 px/sek
|
||||||
|
├── Scroll viewport: scrollTop += scrollDelta
|
||||||
|
└── EMIT: edge-scroll:tick
|
||||||
|
└── DragDropManager kompenserer element position
|
||||||
|
|
||||||
|
pointerup
|
||||||
|
└── DragDropManager.handlePointerUp()
|
||||||
|
├── Snap currentY til grid (15-min intervaller)
|
||||||
|
├── Fjern ghost element
|
||||||
|
├── EMIT: event:drag-end
|
||||||
|
│ └── EdgeScrollManager stopper scroll
|
||||||
|
└── Persist ændringer til IndexedDB
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Grouping System
|
||||||
|
|
||||||
|
### ViewConfig
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ViewConfig {
|
||||||
|
templateId: string; // 'day' | 'simple' | 'resource' | 'team'
|
||||||
|
groupings: GroupingConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupingConfig {
|
||||||
|
type: string; // 'date' | 'resource' | 'team'
|
||||||
|
values: string[]; // IDs der skal vises
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sådan bestemmer groupings strukturen
|
||||||
|
|
||||||
|
1. **Kolonneantal**: Produkt af alle grouping dimensioner
|
||||||
|
- Eksempel: `5 datoer × 2 ressourcer = 10 kolonner`
|
||||||
|
|
||||||
|
2. **Header layout**: CSS grid bruger `data-levels` til at stakke headers
|
||||||
|
- `data-levels="date"` → 1 header række
|
||||||
|
- `data-levels="resource date"` → 2 header rækker
|
||||||
|
- `data-levels="team resource date"` → 3 header rækker
|
||||||
|
|
||||||
|
3. **Renderer selektion**: Grouping types matches til tilgængelige renderers
|
||||||
|
|
||||||
|
### Konfigurationseksempler
|
||||||
|
|
||||||
|
#### Simple Date View (3 dage, ingen ressourcer)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
templateId: 'simple',
|
||||||
|
groupings: [
|
||||||
|
{ type: 'date', values: ['2024-01-15', '2024-01-16', '2024-01-17'] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resultat:** 3 kolonner, 1 header række
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─── Date1 ───┬─── Date2 ───┬─── Date3 ───┐
|
||||||
|
│ │ │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Resource View (2 ressourcer, 3 dage)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
templateId: 'resource',
|
||||||
|
groupings: [
|
||||||
|
{ type: 'resource', values: ['EMP001', 'EMP002'] },
|
||||||
|
{ type: 'date', values: ['2024-01-15', '2024-01-16', '2024-01-17'] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resultat:** 6 kolonner, 2 header rækker
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───── Resource1 (span 3) ─────┬───── Resource2 (span 3) ─────┐
|
||||||
|
├─── D1 ───┬─── D2 ───┬─── D3 ─┼─── D1 ───┬─── D2 ───┬─── D3 ─┤
|
||||||
|
│ │ │ │ │ │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Team View (2 teams, 2 ressourcer, 3 dage)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
templateId: 'team',
|
||||||
|
groupings: [
|
||||||
|
{ type: 'team', values: ['team1', 'team2'] },
|
||||||
|
{ type: 'resource', values: ['res1', 'res2'] },
|
||||||
|
{ type: 'date', values: ['2024-01-15', '2024-01-16', '2024-01-17'] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resultat:** 12 kolonner, 3 header rækker
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────── Team1 (span 6) ───────────┬─────────── Team2 (span 6) ───────────┐
|
||||||
|
├───── Res1 (span 3) ──┬── Res2 (3) ───┼───── Res1 (span 3) ──┬── Res2 (3) ───┤
|
||||||
|
├── D1 ─┬── D2 ─┬── D3 ┼── D1 ─┬── D2 ─┼── D1 ─┬── D2 ─┬── D3 ┼── D1 ─┬── D2 ─┤
|
||||||
|
│ │ │ │ │ │ │ │ │ │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Header Rendering
|
||||||
|
|
||||||
|
### data-levels Attribut
|
||||||
|
|
||||||
|
`swp-calendar-header` modtager `data-levels` som bestemmer header row layout:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// I CalendarOrchestrator.render()
|
||||||
|
const levels = viewConfig.groupings.map(g => g.type).join(' ');
|
||||||
|
headerContainer.dataset.levels = levels; // "resource date" eller "team resource date"
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS Grid Styling
|
||||||
|
|
||||||
|
```css
|
||||||
|
swp-calendar-header[data-levels="date"] > swp-day-header {
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-calendar-header[data-levels="resource date"] {
|
||||||
|
> swp-resource-header { grid-row: 1; }
|
||||||
|
> swp-day-header { grid-row: 2; }
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-calendar-header[data-levels="team resource date"] {
|
||||||
|
> swp-team-header { grid-row: 1; }
|
||||||
|
> swp-resource-header { grid-row: 2; }
|
||||||
|
> swp-day-header { grid-row: 3; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rendering Pipeline
|
||||||
|
|
||||||
|
1. Renderers eksekveres i den rækkefølge de står i `viewConfig.groupings`
|
||||||
|
2. Hver renderer APPENDER sine headers til `headerContainer`
|
||||||
|
3. CSS bruger `data-levels` + element type til at positionere i korrekt række
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Renderers
|
||||||
|
|
||||||
|
### DateRenderer
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class DateRenderer implements IRenderer {
|
||||||
|
readonly type = 'date';
|
||||||
|
|
||||||
|
render(context: IRenderContext): void {
|
||||||
|
// For HVER ressource (eller én gang hvis ingen):
|
||||||
|
// For HVER dato i filter['date']:
|
||||||
|
// Opret swp-day-header + swp-day-column
|
||||||
|
// Sæt dataset.date & dataset.resourceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ResourceRenderer
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class ResourceRenderer implements IRenderer {
|
||||||
|
readonly type = 'resource';
|
||||||
|
|
||||||
|
async render(context: IRenderContext): Promise<void> {
|
||||||
|
// Load IResource[] fra ResourceService
|
||||||
|
// For HVER ressource:
|
||||||
|
// Opret swp-resource-header
|
||||||
|
// Sæt grid-column: span ${dateCount}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TeamRenderer
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class TeamRenderer implements IRenderer {
|
||||||
|
readonly type = 'team';
|
||||||
|
|
||||||
|
render(context: IRenderContext): void {
|
||||||
|
// For HVERT team:
|
||||||
|
// Tæl ressourcer der tilhører team
|
||||||
|
// Opret swp-team-header
|
||||||
|
// Sæt grid-column: span ${colspan}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Grid Konfiguration
|
||||||
|
|
||||||
|
### IGridConfig
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IGridConfig {
|
||||||
|
hourHeight: number; // pixels per time (fx 60)
|
||||||
|
dayStartHour: number; // fx 6 (06:00)
|
||||||
|
dayEndHour: number; // fx 18 (18:00)
|
||||||
|
snapInterval: number; // minutter (fx 15)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Position Beregning
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function calculateEventPosition(start: Date, end: Date, config: IGridConfig): EventPosition {
|
||||||
|
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
||||||
|
const endMinutes = end.getHours() * 60 + end.getMinutes();
|
||||||
|
|
||||||
|
const dayStartMinutes = config.dayStartHour * 60;
|
||||||
|
const minuteHeight = config.hourHeight / 60;
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: (startMinutes - dayStartMinutes) * minuteHeight,
|
||||||
|
height: (endMinutes - startMinutes) * minuteHeight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Eksempel:** Event 09:00-10:00 med dayStartHour=6, hourHeight=60
|
||||||
|
- startMinutes = 540, dayStartMinutes = 360
|
||||||
|
- top = (540 - 360) × 1 = **180px**
|
||||||
|
- height = (600 - 540) × 1 = **60px**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Z-Index Stack
|
||||||
|
|
||||||
|
```
|
||||||
|
z-index: 10 ← Events (interaktive, draggable)
|
||||||
|
z-index: 5 ← Unavailable zones (visuel, pointer-events: none)
|
||||||
|
z-index: 2 ← Time linjer (baggrund)
|
||||||
|
z-index: 1 ← Kvarter linjer (baggrund)
|
||||||
|
z-index: 0 ← Grid baggrund
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Komponenter
|
||||||
|
|
||||||
|
| Komponent | Ansvar | Fil |
|
||||||
|
|-----------|--------|-----|
|
||||||
|
| **CalendarOrchestrator** | Orkestrerer renderer pipeline | `core/CalendarOrchestrator.ts` |
|
||||||
|
| **DateRenderer** | Opretter dag-headers og kolonner | `features/date/DateRenderer.ts` |
|
||||||
|
| **ResourceRenderer** | Opretter ressource-headers | `features/resource/ResourceRenderer.ts` |
|
||||||
|
| **TeamRenderer** | Opretter team-headers | `features/team/TeamRenderer.ts` |
|
||||||
|
| **EventRenderer** | Renderer events med positioner | `features/event/EventRenderer.ts` |
|
||||||
|
| **ScheduleRenderer** | Renderer unavailable zoner | `features/schedule/ScheduleRenderer.ts` |
|
||||||
|
| **DragDropManager** | Håndterer drag-drop | `managers/DragDropManager.ts` |
|
||||||
|
| **ResizeManager** | Håndterer event resize | `managers/ResizeManager.ts` |
|
||||||
|
| **EdgeScrollManager** | Auto-scroll ved kanter | `managers/EdgeScrollManager.ts` |
|
||||||
|
| **ScrollManager** | Synkroniserer scroll | `core/ScrollManager.ts` |
|
||||||
|
| **EventBus** | Central event dispatcher | `core/EventBus.ts` |
|
||||||
|
| **DateService** | Dato formatering og beregning | `core/DateService.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filer
|
||||||
|
|
||||||
|
```
|
||||||
|
src/v2/
|
||||||
|
├── constants/
|
||||||
|
│ └── CoreEvents.ts # Alle event konstanter
|
||||||
|
├── core/
|
||||||
|
│ ├── CalendarOrchestrator.ts
|
||||||
|
│ ├── DateService.ts
|
||||||
|
│ ├── EventBus.ts
|
||||||
|
│ ├── IGridConfig.ts
|
||||||
|
│ ├── IGroupingRenderer.ts
|
||||||
|
│ ├── RenderBuilder.ts
|
||||||
|
│ ├── ScrollManager.ts
|
||||||
|
│ └── ViewConfig.ts
|
||||||
|
├── features/
|
||||||
|
│ ├── date/
|
||||||
|
│ │ └── DateRenderer.ts
|
||||||
|
│ ├── event/
|
||||||
|
│ │ └── EventRenderer.ts
|
||||||
|
│ ├── resource/
|
||||||
|
│ │ └── ResourceRenderer.ts
|
||||||
|
│ ├── schedule/
|
||||||
|
│ │ └── ScheduleRenderer.ts
|
||||||
|
│ └── team/
|
||||||
|
│ └── TeamRenderer.ts
|
||||||
|
├── managers/
|
||||||
|
│ ├── DragDropManager.ts
|
||||||
|
│ └── EdgeScrollManager.ts
|
||||||
|
├── storage/
|
||||||
|
│ ├── BaseEntityService.ts
|
||||||
|
│ ├── IndexedDBContext.ts
|
||||||
|
│ └── events/
|
||||||
|
│ ├── EventService.ts
|
||||||
|
│ └── EventStore.ts
|
||||||
|
├── types/
|
||||||
|
│ ├── CalendarTypes.ts
|
||||||
|
│ ├── DragTypes.ts
|
||||||
|
│ └── ScheduleTypes.ts
|
||||||
|
└── utils/
|
||||||
|
└── PositionUtils.ts
|
||||||
|
```
|
||||||
|
|
@ -44,6 +44,7 @@ import { DataSeeder } from './workers/DataSeeder';
|
||||||
// Features
|
// Features
|
||||||
import { EventRenderer } from './features/event/EventRenderer';
|
import { EventRenderer } from './features/event/EventRenderer';
|
||||||
import { ScheduleRenderer } from './features/schedule/ScheduleRenderer';
|
import { ScheduleRenderer } from './features/schedule/ScheduleRenderer';
|
||||||
|
import { HeaderDrawerRenderer } from './features/headerdrawer/HeaderDrawerRenderer';
|
||||||
|
|
||||||
// Schedule
|
// Schedule
|
||||||
import { ScheduleOverrideStore } from './storage/schedules/ScheduleOverrideStore';
|
import { ScheduleOverrideStore } from './storage/schedules/ScheduleOverrideStore';
|
||||||
|
|
@ -135,6 +136,7 @@ export function createV2Container(): Container {
|
||||||
// Features
|
// Features
|
||||||
builder.registerType(EventRenderer).as<EventRenderer>();
|
builder.registerType(EventRenderer).as<EventRenderer>();
|
||||||
builder.registerType(ScheduleRenderer).as<ScheduleRenderer>();
|
builder.registerType(ScheduleRenderer).as<ScheduleRenderer>();
|
||||||
|
builder.registerType(HeaderDrawerRenderer).as<HeaderDrawerRenderer>();
|
||||||
|
|
||||||
// Renderers - registreres som Renderer (array injection til CalendarOrchestrator)
|
// Renderers - registreres som Renderer (array injection til CalendarOrchestrator)
|
||||||
builder.registerType(DateRenderer).as<IRenderer>();
|
builder.registerType(DateRenderer).as<IRenderer>();
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,11 @@ export const CoreEvents = {
|
||||||
EVENT_DRAG_CANCEL: 'event:drag-cancel',
|
EVENT_DRAG_CANCEL: 'event:drag-cancel',
|
||||||
EVENT_DRAG_COLUMN_CHANGE: 'event:drag-column-change',
|
EVENT_DRAG_COLUMN_CHANGE: 'event:drag-column-change',
|
||||||
|
|
||||||
|
// Header drag (timed → header conversion)
|
||||||
|
EVENT_DRAG_ENTER_HEADER: 'event:drag-enter-header',
|
||||||
|
EVENT_DRAG_MOVE_HEADER: 'event:drag-move-header',
|
||||||
|
EVENT_DRAG_LEAVE_HEADER: 'event:drag-leave-header',
|
||||||
|
|
||||||
// Event resize
|
// Event resize
|
||||||
EVENT_RESIZE_START: 'event:resize-start',
|
EVENT_RESIZE_START: 'event:resize-start',
|
||||||
EVENT_RESIZE_END: 'event:resize-end',
|
EVENT_RESIZE_END: 'event:resize-end',
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { ViewConfig } from '../core/ViewConfig';
|
||||||
import { DragDropManager } from '../managers/DragDropManager';
|
import { DragDropManager } from '../managers/DragDropManager';
|
||||||
import { EdgeScrollManager } from '../managers/EdgeScrollManager';
|
import { EdgeScrollManager } from '../managers/EdgeScrollManager';
|
||||||
import { ResizeManager } from '../managers/ResizeManager';
|
import { ResizeManager } from '../managers/ResizeManager';
|
||||||
|
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
|
||||||
|
|
||||||
export class DemoApp {
|
export class DemoApp {
|
||||||
private animator!: NavigationAnimator;
|
private animator!: NavigationAnimator;
|
||||||
|
|
@ -27,7 +28,8 @@ export class DemoApp {
|
||||||
private dataSeeder: DataSeeder,
|
private dataSeeder: DataSeeder,
|
||||||
private dragDropManager: DragDropManager,
|
private dragDropManager: DragDropManager,
|
||||||
private edgeScrollManager: EdgeScrollManager,
|
private edgeScrollManager: EdgeScrollManager,
|
||||||
private resizeManager: ResizeManager
|
private resizeManager: ResizeManager,
|
||||||
|
private headerDrawerRenderer: HeaderDrawerRenderer
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
|
|
|
||||||
135
src/v2/features/headerdrawer/HeaderDrawerLayoutEngine.ts
Normal file
135
src/v2/features/headerdrawer/HeaderDrawerLayoutEngine.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
/**
|
||||||
|
* HeaderDrawerLayoutEngine - Calculates row placement for header items
|
||||||
|
*
|
||||||
|
* Prevents visual overlap by assigning items to different rows when
|
||||||
|
* they occupy the same columns. Uses a track-based algorithm similar
|
||||||
|
* to V1's AllDayLayoutEngine.
|
||||||
|
*
|
||||||
|
* Each row can hold multiple items as long as they don't overlap in columns.
|
||||||
|
* When an item spans columns that are already occupied, it's placed in the
|
||||||
|
* next available row.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface IHeaderItemLayout {
|
||||||
|
itemId: string;
|
||||||
|
gridArea: string; // "row / col-start / row+1 / col-end"
|
||||||
|
startColumn: number;
|
||||||
|
endColumn: number;
|
||||||
|
row: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHeaderItemInput {
|
||||||
|
id: string;
|
||||||
|
columnStart: number; // 0-based column index
|
||||||
|
columnEnd: number; // 0-based end column (inclusive)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HeaderDrawerLayoutEngine {
|
||||||
|
private tracks: boolean[][] = [];
|
||||||
|
private columnCount: number;
|
||||||
|
|
||||||
|
constructor(columnCount: number) {
|
||||||
|
this.columnCount = columnCount;
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset tracks for new layout calculation
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
this.tracks = [new Array(this.columnCount).fill(false)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate layout for all items
|
||||||
|
* Items should be sorted by start column for optimal packing
|
||||||
|
*/
|
||||||
|
calculateLayout(items: IHeaderItemInput[]): IHeaderItemLayout[] {
|
||||||
|
this.reset();
|
||||||
|
const layouts: IHeaderItemLayout[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const row = this.findAvailableRow(item.columnStart, item.columnEnd);
|
||||||
|
|
||||||
|
// Mark columns as occupied in this row
|
||||||
|
for (let col = item.columnStart; col <= item.columnEnd; col++) {
|
||||||
|
this.tracks[row][col] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// gridArea format: "row / col-start / row+1 / col-end"
|
||||||
|
// CSS grid uses 1-based indices
|
||||||
|
layouts.push({
|
||||||
|
itemId: item.id,
|
||||||
|
gridArea: `${row + 1} / ${item.columnStart + 1} / ${row + 2} / ${item.columnEnd + 2}`,
|
||||||
|
startColumn: item.columnStart,
|
||||||
|
endColumn: item.columnEnd,
|
||||||
|
row: row + 1 // 1-based for CSS
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return layouts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate layout for a single new item
|
||||||
|
* Useful for real-time drag operations
|
||||||
|
*/
|
||||||
|
calculateSingleLayout(item: IHeaderItemInput): IHeaderItemLayout {
|
||||||
|
const row = this.findAvailableRow(item.columnStart, item.columnEnd);
|
||||||
|
|
||||||
|
// Mark columns as occupied
|
||||||
|
for (let col = item.columnStart; col <= item.columnEnd; col++) {
|
||||||
|
this.tracks[row][col] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemId: item.id,
|
||||||
|
gridArea: `${row + 1} / ${item.columnStart + 1} / ${row + 2} / ${item.columnEnd + 2}`,
|
||||||
|
startColumn: item.columnStart,
|
||||||
|
endColumn: item.columnEnd,
|
||||||
|
row: row + 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the first row where all columns in range are available
|
||||||
|
*/
|
||||||
|
private findAvailableRow(startCol: number, endCol: number): number {
|
||||||
|
for (let row = 0; row < this.tracks.length; row++) {
|
||||||
|
if (this.isRowAvailable(row, startCol, endCol)) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new row if all existing rows are occupied
|
||||||
|
this.tracks.push(new Array(this.columnCount).fill(false));
|
||||||
|
return this.tracks.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if columns in range are all available in given row
|
||||||
|
*/
|
||||||
|
private isRowAvailable(row: number, startCol: number, endCol: number): boolean {
|
||||||
|
for (let col = startCol; col <= endCol; col++) {
|
||||||
|
if (this.tracks[row][col]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of rows currently in use
|
||||||
|
*/
|
||||||
|
getRowCount(): number {
|
||||||
|
return this.tracks.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update column count (e.g., when view changes)
|
||||||
|
*/
|
||||||
|
setColumnCount(count: number): void {
|
||||||
|
this.columnCount = count;
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/v2/features/headerdrawer/HeaderDrawerRenderer.ts
Normal file
151
src/v2/features/headerdrawer/HeaderDrawerRenderer.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { IEventBus } from '../../types/CalendarTypes';
|
||||||
|
import { IGridConfig } from '../../core/IGridConfig';
|
||||||
|
import { CoreEvents } from '../../constants/CoreEvents';
|
||||||
|
import {
|
||||||
|
IDragEnterHeaderPayload,
|
||||||
|
IDragMoveHeaderPayload,
|
||||||
|
IDragLeaveHeaderPayload
|
||||||
|
} from '../../types/DragTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HeaderDrawerRenderer - Handles rendering of items in the header drawer
|
||||||
|
*
|
||||||
|
* Listens to drag events from DragDropManager and creates/manages
|
||||||
|
* swp-header-item elements in the header drawer.
|
||||||
|
*
|
||||||
|
* Uses subgrid for column alignment with parent swp-calendar-header.
|
||||||
|
* Position items via gridArea for explicit row/column placement.
|
||||||
|
*/
|
||||||
|
export class HeaderDrawerRenderer {
|
||||||
|
private currentItem: HTMLElement | null = null;
|
||||||
|
private container: HTMLElement | null = null;
|
||||||
|
private sourceElement: HTMLElement | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private eventBus: IEventBus,
|
||||||
|
private gridConfig: IGridConfig
|
||||||
|
) {
|
||||||
|
this.setupListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup event listeners for drag events
|
||||||
|
*/
|
||||||
|
private setupListeners(): void {
|
||||||
|
this.eventBus.on(CoreEvents.EVENT_DRAG_ENTER_HEADER, (e) => {
|
||||||
|
const payload = (e as CustomEvent<IDragEnterHeaderPayload>).detail;
|
||||||
|
this.handleDragEnter(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE_HEADER, (e) => {
|
||||||
|
const payload = (e as CustomEvent<IDragMoveHeaderPayload>).detail;
|
||||||
|
this.handleDragMove(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventBus.on(CoreEvents.EVENT_DRAG_LEAVE_HEADER, (e) => {
|
||||||
|
const payload = (e as CustomEvent<IDragLeaveHeaderPayload>).detail;
|
||||||
|
this.handleDragLeave(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventBus.on(CoreEvents.EVENT_DRAG_END, () => {
|
||||||
|
this.handleDragEnd();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventBus.on(CoreEvents.EVENT_DRAG_CANCEL, () => {
|
||||||
|
this.cleanup();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle drag entering header zone - create preview item
|
||||||
|
*/
|
||||||
|
private handleDragEnter(payload: IDragEnterHeaderPayload): void {
|
||||||
|
this.container = document.querySelector('swp-header-drawer');
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
|
// Store reference to source element
|
||||||
|
this.sourceElement = payload.element;
|
||||||
|
|
||||||
|
// Create header item
|
||||||
|
const item = document.createElement('swp-header-item');
|
||||||
|
item.dataset.id = payload.eventId;
|
||||||
|
item.dataset.itemType = payload.itemType;
|
||||||
|
item.dataset.date = payload.sourceDate;
|
||||||
|
item.dataset.duration = String(payload.duration);
|
||||||
|
item.textContent = payload.title;
|
||||||
|
|
||||||
|
// Apply color class if present
|
||||||
|
if (payload.colorClass) {
|
||||||
|
item.classList.add(payload.colorClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add dragging state
|
||||||
|
item.classList.add('dragging');
|
||||||
|
|
||||||
|
// Initial placement (duration determines column span)
|
||||||
|
// gridArea format: "row / col-start / row+1 / col-end"
|
||||||
|
const col = payload.sourceColumnIndex + 1;
|
||||||
|
const endCol = col + payload.duration;
|
||||||
|
item.style.gridArea = `1 / ${col} / 2 / ${endCol}`;
|
||||||
|
|
||||||
|
this.container.appendChild(item);
|
||||||
|
this.currentItem = item;
|
||||||
|
|
||||||
|
// Hide original element while in header
|
||||||
|
payload.element.style.visibility = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle drag moving within header - update column position
|
||||||
|
*/
|
||||||
|
private handleDragMove(payload: IDragMoveHeaderPayload): void {
|
||||||
|
if (!this.currentItem) return;
|
||||||
|
|
||||||
|
// Update column position (duration=1 for now)
|
||||||
|
const col = payload.columnIndex + 1;
|
||||||
|
const duration = parseInt(this.currentItem.dataset.duration || '1', 10);
|
||||||
|
const endCol = col + duration;
|
||||||
|
|
||||||
|
this.currentItem.style.gridArea = `1 / ${col} / 2 / ${endCol}`;
|
||||||
|
this.currentItem.dataset.date = payload.dateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle drag leaving header - remove preview and restore source
|
||||||
|
*/
|
||||||
|
private handleDragLeave(_payload: IDragLeaveHeaderPayload): void {
|
||||||
|
this.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle drag end - finalize the item (it stays in header)
|
||||||
|
*/
|
||||||
|
private handleDragEnd(): void {
|
||||||
|
if (!this.currentItem) return;
|
||||||
|
|
||||||
|
// Remove dragging state
|
||||||
|
this.currentItem.classList.remove('dragging');
|
||||||
|
|
||||||
|
// Item stays - it's now permanent
|
||||||
|
// TODO: Emit event to persist allDay=true change
|
||||||
|
|
||||||
|
// Clear references but leave item in DOM
|
||||||
|
this.currentItem = null;
|
||||||
|
this.sourceElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup preview item and restore source visibility
|
||||||
|
*/
|
||||||
|
private cleanup(): void {
|
||||||
|
// Remove preview item
|
||||||
|
this.currentItem?.remove();
|
||||||
|
this.currentItem = null;
|
||||||
|
|
||||||
|
// Restore source element visibility
|
||||||
|
if (this.sourceElement) {
|
||||||
|
this.sourceElement.style.visibility = '';
|
||||||
|
this.sourceElement = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/v2/features/headerdrawer/index.ts
Normal file
2
src/v2/features/headerdrawer/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { HeaderDrawerRenderer } from './HeaderDrawerRenderer';
|
||||||
|
export { HeaderDrawerLayoutEngine, type IHeaderItemLayout, type IHeaderItemInput } from './HeaderDrawerLayoutEngine';
|
||||||
|
|
@ -8,7 +8,10 @@ import {
|
||||||
IDragMovePayload,
|
IDragMovePayload,
|
||||||
IDragEndPayload,
|
IDragEndPayload,
|
||||||
IDragCancelPayload,
|
IDragCancelPayload,
|
||||||
IDragColumnChangePayload
|
IDragColumnChangePayload,
|
||||||
|
IDragEnterHeaderPayload,
|
||||||
|
IDragMoveHeaderPayload,
|
||||||
|
IDragLeaveHeaderPayload
|
||||||
} from '../types/DragTypes';
|
} from '../types/DragTypes';
|
||||||
|
|
||||||
interface DragState {
|
interface DragState {
|
||||||
|
|
@ -39,6 +42,7 @@ export class DragDropManager {
|
||||||
private pendingElement: HTMLElement | null = null;
|
private pendingElement: HTMLElement | null = null;
|
||||||
private pendingMouseOffset: IMousePosition | null = null;
|
private pendingMouseOffset: IMousePosition | null = null;
|
||||||
private container: HTMLElement | null = null;
|
private container: HTMLElement | null = null;
|
||||||
|
private inHeader = false;
|
||||||
|
|
||||||
private readonly DRAG_THRESHOLD = 5;
|
private readonly DRAG_THRESHOLD = 5;
|
||||||
private readonly INTERPOLATION_FACTOR = 0.3;
|
private readonly INTERPOLATION_FACTOR = 0.3;
|
||||||
|
|
@ -159,6 +163,7 @@ export class DragDropManager {
|
||||||
// Cleanup
|
// Cleanup
|
||||||
this.dragState.element.classList.remove('dragging');
|
this.dragState.element.classList.remove('dragging');
|
||||||
this.dragState = null;
|
this.dragState = null;
|
||||||
|
this.inHeader = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
private initializeDrag(element: HTMLElement, mouseOffset: IMousePosition, e: PointerEvent): void {
|
private initializeDrag(element: HTMLElement, mouseOffset: IMousePosition, e: PointerEvent): void {
|
||||||
|
|
@ -217,6 +222,12 @@ export class DragDropManager {
|
||||||
private updateDragTarget(e: PointerEvent): void {
|
private updateDragTarget(e: PointerEvent): void {
|
||||||
if (!this.dragState) return;
|
if (!this.dragState) return;
|
||||||
|
|
||||||
|
// Check header zone first
|
||||||
|
this.checkHeaderZone(e);
|
||||||
|
|
||||||
|
// Skip normal grid handling if in header
|
||||||
|
if (this.inHeader) return;
|
||||||
|
|
||||||
// Check for column change
|
// Check for column change
|
||||||
const columnAtPoint = this.getColumnAtPoint(e.clientX);
|
const columnAtPoint = this.getColumnAtPoint(e.clientX);
|
||||||
if (columnAtPoint && columnAtPoint !== this.dragState.currentColumn) {
|
if (columnAtPoint && columnAtPoint !== this.dragState.currentColumn) {
|
||||||
|
|
@ -244,6 +255,74 @@ export class DragDropManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if pointer is in header zone and emit appropriate events
|
||||||
|
*/
|
||||||
|
private checkHeaderZone(e: PointerEvent): void {
|
||||||
|
if (!this.dragState) return;
|
||||||
|
|
||||||
|
const headerViewport = document.querySelector('swp-header-viewport');
|
||||||
|
if (!headerViewport) return;
|
||||||
|
|
||||||
|
const rect = headerViewport.getBoundingClientRect();
|
||||||
|
const isInHeader = e.clientY < rect.bottom;
|
||||||
|
|
||||||
|
if (isInHeader && !this.inHeader) {
|
||||||
|
// Entered header
|
||||||
|
this.inHeader = true;
|
||||||
|
|
||||||
|
const payload: IDragEnterHeaderPayload = {
|
||||||
|
eventId: this.dragState.eventId,
|
||||||
|
element: this.dragState.element,
|
||||||
|
sourceColumnIndex: this.getColumnIndex(this.dragState.columnElement),
|
||||||
|
sourceDate: this.dragState.columnElement.dataset.date || '',
|
||||||
|
title: this.dragState.element.querySelector('swp-event-title')?.textContent || '',
|
||||||
|
colorClass: [...this.dragState.element.classList].find(c => c.startsWith('is-')),
|
||||||
|
itemType: 'event',
|
||||||
|
duration: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
this.eventBus.emit(CoreEvents.EVENT_DRAG_ENTER_HEADER, payload);
|
||||||
|
} else if (!isInHeader && this.inHeader) {
|
||||||
|
// Left header
|
||||||
|
this.inHeader = false;
|
||||||
|
|
||||||
|
const payload: IDragLeaveHeaderPayload = {
|
||||||
|
eventId: this.dragState.eventId
|
||||||
|
};
|
||||||
|
|
||||||
|
this.eventBus.emit(CoreEvents.EVENT_DRAG_LEAVE_HEADER, payload);
|
||||||
|
} else if (isInHeader) {
|
||||||
|
// Moving within header
|
||||||
|
const column = this.getColumnAtX(e.clientX);
|
||||||
|
if (column) {
|
||||||
|
const payload: IDragMoveHeaderPayload = {
|
||||||
|
eventId: this.dragState.eventId,
|
||||||
|
columnIndex: this.getColumnIndex(column),
|
||||||
|
dateKey: column.dataset.date || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
this.eventBus.emit(CoreEvents.EVENT_DRAG_MOVE_HEADER, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get column index (0-based) for a column element
|
||||||
|
*/
|
||||||
|
private getColumnIndex(column: HTMLElement): number {
|
||||||
|
if (!this.container) return 0;
|
||||||
|
const columns = Array.from(this.container.querySelectorAll('swp-day-column'));
|
||||||
|
return columns.indexOf(column);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get column at X coordinate (alias for getColumnAtPoint)
|
||||||
|
*/
|
||||||
|
private getColumnAtX(clientX: number): HTMLElement | null {
|
||||||
|
return this.getColumnAtPoint(clientX);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find column element at given X coordinate
|
* Find column element at given X coordinate
|
||||||
*/
|
*/
|
||||||
|
|
@ -323,5 +402,6 @@ export class DragDropManager {
|
||||||
this.eventBus.emit(CoreEvents.EVENT_DRAG_CANCEL, payload);
|
this.eventBus.emit(CoreEvents.EVENT_DRAG_CANCEL, payload);
|
||||||
|
|
||||||
this.dragState = null;
|
this.dragState = null;
|
||||||
|
this.inHeader = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,3 +45,25 @@ export interface IDragColumnChangePayload {
|
||||||
newColumn: HTMLElement;
|
newColumn: HTMLElement;
|
||||||
currentY: number;
|
currentY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Header drag payloads
|
||||||
|
export interface IDragEnterHeaderPayload {
|
||||||
|
eventId: string;
|
||||||
|
element: HTMLElement; // Original dragged element
|
||||||
|
sourceColumnIndex: number;
|
||||||
|
sourceDate: string;
|
||||||
|
title: string;
|
||||||
|
colorClass?: string;
|
||||||
|
itemType: 'event' | 'reminder';
|
||||||
|
duration: number; // Antal dage (default 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDragMoveHeaderPayload {
|
||||||
|
eventId: string;
|
||||||
|
columnIndex: number;
|
||||||
|
dateKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDragLeaveHeaderPayload {
|
||||||
|
eventId: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -346,3 +346,38 @@ swp-allday-container swp-event.transitioning {
|
||||||
.is-amber { --b-primary: var(--b-color-amber); }
|
.is-amber { --b-primary: var(--b-color-amber); }
|
||||||
.is-orange { --b-primary: var(--b-color-orange); }
|
.is-orange { --b-primary: var(--b-color-orange); }
|
||||||
.is-deep-orange { --b-primary: var(--b-color-deep-orange); }
|
.is-deep-orange { --b-primary: var(--b-color-deep-orange); }
|
||||||
|
|
||||||
|
/* Header drawer items */
|
||||||
|
swp-header-item {
|
||||||
|
--b-text: var(--color-text);
|
||||||
|
|
||||||
|
/* Positioneres via style.gridArea */
|
||||||
|
height: 22px;
|
||||||
|
margin: 2px 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background-color 200ms ease;
|
||||||
|
|
||||||
|
/* Color system fra swp-event */
|
||||||
|
background-color: color-mix(in srgb, var(--b-primary) 10%, var(--b-mix));
|
||||||
|
color: var(--b-text);
|
||||||
|
border-left: 4px solid var(--b-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--b-primary) 15%, var(--b-mix));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dragging state */
|
||||||
|
&.dragging {
|
||||||
|
opacity: 0.7;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,12 @@ swp-header-spacer {
|
||||||
}
|
}
|
||||||
|
|
||||||
swp-header-drawer {
|
swp-header-drawer {
|
||||||
display: block;
|
display: grid;
|
||||||
height: 0;
|
grid-template-columns: repeat(var(--grid-columns), minmax(var(--day-column-min-width), 1fr));
|
||||||
|
min-width: calc(var(--grid-columns) * var(--day-column-min-width));
|
||||||
|
grid-auto-rows: 28px;
|
||||||
|
gap: 2px 0;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-background-alt);
|
background: var(--color-background-alt);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue