Refactors event element handling with web components

Introduces web components for event elements, separating timed and all-day events into distinct components for better organization and reusability.

This change also simplifies event rendering and drag-and-drop operations by leveraging the properties and lifecycle methods of web components.
This commit is contained in:
Janus C. H. Knudsen 2025-10-04 15:35:09 +02:00
parent 1a47214831
commit a9d6d14c93
6 changed files with 253 additions and 274 deletions

View file

@ -2,166 +2,243 @@ import { CalendarEvent } from '../types/CalendarTypes';
import { calendarConfig } from '../core/CalendarConfig'; import { calendarConfig } from '../core/CalendarConfig';
import { TimeFormatter } from '../utils/TimeFormatter'; import { TimeFormatter } from '../utils/TimeFormatter';
import { PositionUtils } from '../utils/PositionUtils'; import { PositionUtils } from '../utils/PositionUtils';
import { EventLayout } from '../utils/AllDayLayoutEngine';
import { DateService } from '../utils/DateService'; import { DateService } from '../utils/DateService';
/** /**
* Abstract base class for event DOM elements * Base class for event elements
*/ */
export abstract class BaseEventElement { abstract class BaseSwpEventElement extends HTMLElement {
protected element: HTMLElement;
protected event: CalendarEvent;
protected dateService: DateService; protected dateService: DateService;
protected constructor(event: CalendarEvent) { constructor() {
this.event = event; super();
const timezone = calendarConfig.getTimezone?.(); const timezone = calendarConfig.getTimezone?.();
this.dateService = new DateService(timezone); this.dateService = new DateService(timezone);
this.element = this.createElement();
this.setDataAttributes();
} }
/** // ============================================
* Create the underlying DOM element // Common Getters/Setters
*/ // ============================================
protected abstract createElement(): HTMLElement;
/** get eventId(): string {
* Set standard data attributes on the element return this.dataset.eventId || '';
*/ }
protected setDataAttributes(): void { set eventId(value: string) {
this.element.dataset.eventId = this.event.id; this.dataset.eventId = value;
this.element.dataset.title = this.event.title;
this.element.dataset.start = this.dateService.toUTC(this.event.start);
this.element.dataset.end = this.dateService.toUTC(this.event.end);
this.element.dataset.type = this.event.type;
this.element.dataset.duration = this.event.metadata?.duration?.toString() || '60';
} }
/** get start(): Date {
* Get the DOM element return new Date(this.dataset.start || '');
*/ }
public getElement(): HTMLElement { set start(value: Date) {
return this.element; this.dataset.start = this.dateService.toUTC(value);
} }
/** get end(): Date {
* Format time for display using TimeFormatter return new Date(this.dataset.end || '');
*/ }
protected formatTime(date: Date): string { set end(value: Date) {
return TimeFormatter.formatTime(date); this.dataset.end = this.dateService.toUTC(value);
} }
/** get title(): string {
* Calculate event position for timed events using PositionUtils return this.dataset.title || '';
*/ }
protected calculateEventPosition(): { top: number; height: number } { set title(value: string) {
return PositionUtils.calculateEventPosition(this.event.start, this.event.end); this.dataset.title = value;
}
get type(): string {
return this.dataset.type || 'work';
}
set type(value: string) {
this.dataset.type = value;
} }
} }
/** /**
* Timed event element (swp-event) * Web Component for timed calendar events (Light DOM)
*/ */
export class SwpEventElement extends BaseEventElement { export class SwpEventElement extends BaseSwpEventElement {
private constructor(event: CalendarEvent) {
super(event);
this.createInnerStructure();
this.applyPositioning();
}
protected createElement(): HTMLElement { /**
return document.createElement('swp-event'); * Observed attributes - changes trigger attributeChangedCallback
*/
static get observedAttributes() {
return ['data-start', 'data-end', 'data-title', 'data-type'];
} }
/** /**
* Create inner HTML structure * Called when element is added to DOM
*/ */
private createInnerStructure(): void { connectedCallback() {
const timeRange = TimeFormatter.formatTimeRange(this.event.start, this.event.end); if (!this.hasChildNodes()) {
const durationMinutes = (this.event.end.getTime() - this.event.start.getTime()) / (1000 * 60); this.render();
}
this.applyPositioning();
}
this.element.innerHTML = ` /**
* Called when observed attribute changes
*/
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (oldValue !== newValue && this.isConnected) {
this.updateDisplay();
}
}
// ============================================
// Public Methods
// ============================================
/**
* Update event position during drag
* @param columnDate - The date of the column
* @param snappedY - The Y position in pixels
*/
public updatePosition(columnDate: Date, snappedY: number): void {
// 1. Update visual position
this.style.top = `${snappedY + 1}px`;
// 2. Calculate new timestamps
const { startMinutes, endMinutes } = this.calculateTimesFromPosition(snappedY);
// 3. Update data attributes (triggers attributeChangedCallback)
const startDate = this.dateService.createDateAtTime(columnDate, startMinutes);
let endDate = this.dateService.createDateAtTime(columnDate, endMinutes);
// Handle cross-midnight events
if (endMinutes >= 1440) {
const extraDays = Math.floor(endMinutes / 1440);
endDate = this.dateService.addDays(endDate, extraDays);
}
this.start = startDate;
this.end = endDate;
}
/**
* Create a clone for drag operations
*/
public createClone(): SwpEventElement {
const clone = this.cloneNode(true) as SwpEventElement;
// Apply "clone-" prefix to ID
clone.dataset.eventId = `clone-${this.eventId}`;
// Cache original duration
const timeEl = this.querySelector('swp-event-time');
if (timeEl) {
const duration = timeEl.getAttribute('data-duration');
if (duration) {
clone.dataset.originalDuration = duration;
}
}
// Set height from original
clone.style.height = this.style.height || `${this.getBoundingClientRect().height}px`;
return clone;
}
// ============================================
// Private Methods
// ============================================
/**
* Render inner HTML structure
*/
private render(): void {
const start = this.start;
const end = this.end;
const timeRange = TimeFormatter.formatTimeRange(start, end);
const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60);
this.innerHTML = `
<swp-event-time data-duration="${durationMinutes}">${timeRange}</swp-event-time> <swp-event-time data-duration="${durationMinutes}">${timeRange}</swp-event-time>
<swp-event-title>${this.event.title}</swp-event-title> <swp-event-title>${this.title}</swp-event-title>
`; `;
} }
/** /**
* Apply positioning styles * Update time display when attributes change
*/
private updateDisplay(): void {
const timeEl = this.querySelector('swp-event-time');
const titleEl = this.querySelector('swp-event-title');
if (timeEl && this.dataset.start && this.dataset.end) {
const start = new Date(this.dataset.start);
const end = new Date(this.dataset.end);
const timeRange = TimeFormatter.formatTimeRange(start, end);
timeEl.textContent = timeRange;
// Update duration attribute
const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60);
timeEl.setAttribute('data-duration', durationMinutes.toString());
}
if (titleEl && this.dataset.title) {
titleEl.textContent = this.dataset.title;
}
}
/**
* Apply initial positioning based on start/end times
*/ */
private applyPositioning(): void { private applyPositioning(): void {
const position = this.calculateEventPosition(); const position = PositionUtils.calculateEventPosition(this.start, this.end);
this.element.style.top = `${position.top + 1}px`; this.style.top = `${position.top + 1}px`;
this.element.style.height = `${position.height - 3}px`; this.style.height = `${position.height - 3}px`;
this.element.style.left = '2px'; this.style.left = '2px';
this.element.style.right = '2px'; this.style.right = '2px';
} }
/** /**
* Factory method to create a SwpEventElement from a CalendarEvent * Calculate start/end minutes from Y position
*/
private calculateTimesFromPosition(snappedY: number): { startMinutes: number; endMinutes: number } {
const gridSettings = calendarConfig.getGridSettings();
const { hourHeight, dayStartHour, snapInterval } = gridSettings;
// Get original duration
const originalDuration = parseInt(
this.dataset.originalDuration ||
this.dataset.duration ||
'60'
);
// Calculate snapped start minutes
const minutesFromGridStart = (snappedY / hourHeight) * 60;
const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart;
const snappedStartMinutes = Math.round(actualStartMinutes / snapInterval) * snapInterval;
// Calculate end minutes
const endMinutes = snappedStartMinutes + originalDuration;
return { startMinutes: snappedStartMinutes, endMinutes };
}
// ============================================
// Static Factory Methods
// ============================================
/**
* Create SwpEventElement from CalendarEvent
*/ */
public static fromCalendarEvent(event: CalendarEvent): SwpEventElement { public static fromCalendarEvent(event: CalendarEvent): SwpEventElement {
return new SwpEventElement(event); const element = document.createElement('swp-event') as SwpEventElement;
} const timezone = calendarConfig.getTimezone?.();
const dateService = new DateService(timezone);
/** element.dataset.eventId = event.id;
* Create a clone of this SwpEventElement with "clone-" prefix element.dataset.title = event.title;
*/ element.dataset.start = dateService.toUTC(event.start);
public createClone(): SwpEventElement { element.dataset.end = dateService.toUTC(event.end);
// Clone the underlying DOM element element.dataset.type = event.type;
const clonedElement = this.element.cloneNode(true) as HTMLElement; element.dataset.duration = event.metadata?.duration?.toString() || '60';
// Create new SwpEventElement instance from the cloned DOM return element;
const clonedSwpEvent = SwpEventElement.fromExistingElement(clonedElement);
// Apply "clone-" prefix to ID
clonedSwpEvent.updateEventId(`clone-${this.event.id}`);
// Cache original duration for drag operations
const originalDuration = this.getOriginalEventDuration();
clonedSwpEvent.element.dataset.originalDuration = originalDuration.toString();
// Set height from original element
clonedSwpEvent.element.style.height = this.element.style.height || `${this.element.getBoundingClientRect().height}px`;
return clonedSwpEvent;
}
/**
* Factory method to create SwpEventElement from existing DOM element
*/
public static fromExistingElement(element: HTMLElement): SwpEventElement {
// Extract CalendarEvent data from DOM element
const event = this.extractCalendarEventFromElement(element);
// Create new instance but replace the created element with the existing one
const swpEvent = new SwpEventElement(event);
swpEvent.element = element;
return swpEvent;
}
/**
* Update the event ID in both the CalendarEvent and DOM element
*/
private updateEventId(newId: string): void {
this.event.id = newId;
this.element.dataset.eventId = newId;
}
/**
* Extract original event duration from DOM element
*/
private getOriginalEventDuration(): number {
const timeElement = this.element.querySelector('swp-event-time');
if (timeElement) {
const duration = timeElement.getAttribute('data-duration');
if (duration) {
return parseInt(duration);
}
}
return 60; // Fallback
} }
/** /**
@ -186,7 +263,6 @@ export class SwpEventElement extends BaseEventElement {
* Factory method to convert an all-day HTML element to a timed SwpEventElement * Factory method to convert an all-day HTML element to a timed SwpEventElement
*/ */
public static fromAllDayElement(allDayElement: HTMLElement): SwpEventElement { public static fromAllDayElement(allDayElement: HTMLElement): SwpEventElement {
// Extract data from all-day element's dataset
const eventId = allDayElement.dataset.eventId || ''; const eventId = allDayElement.dataset.eventId || '';
const title = allDayElement.dataset.title || allDayElement.textContent || 'Untitled'; const title = allDayElement.dataset.title || allDayElement.textContent || 'Untitled';
const type = allDayElement.dataset.type || 'work'; const type = allDayElement.dataset.type || 'work';
@ -198,11 +274,9 @@ export class SwpEventElement extends BaseEventElement {
throw new Error('All-day element missing start/end dates'); throw new Error('All-day element missing start/end dates');
} }
// Parse dates and set reasonable 1-hour duration for timed event
const originalStart = new Date(startStr); const originalStart = new Date(startStr);
const duration = durationStr ? parseInt(durationStr) : 60; // Default 1 hour const duration = durationStr ? parseInt(durationStr) : 60;
// For conversion, use current time or a reasonable default (9 AM)
const now = new Date(); const now = new Date();
const startDate = new Date(originalStart); const startDate = new Date(originalStart);
startDate.setHours(now.getHours() || 9, now.getMinutes() || 0, 0, 0); startDate.setHours(now.getHours() || 9, now.getMinutes() || 0, 0, 0);
@ -210,7 +284,6 @@ export class SwpEventElement extends BaseEventElement {
const endDate = new Date(startDate); const endDate = new Date(startDate);
endDate.setMinutes(endDate.getMinutes() + duration); endDate.setMinutes(endDate.getMinutes() + duration);
// Create CalendarEvent object
const calendarEvent: CalendarEvent = { const calendarEvent: CalendarEvent = {
id: eventId, id: eventId,
title: title, title: title,
@ -224,48 +297,49 @@ export class SwpEventElement extends BaseEventElement {
} }
}; };
return new SwpEventElement(calendarEvent); return SwpEventElement.fromCalendarEvent(calendarEvent);
} }
} }
/** /**
* All-day event element (now using unified swp-event tag) * Web Component for all-day calendar events
*/ */
export class SwpAllDayEventElement extends BaseEventElement { export class SwpAllDayEventElement extends BaseSwpEventElement {
constructor(event: CalendarEvent) { connectedCallback() {
super(event); if (!this.textContent) {
this.setAllDayAttributes(); this.textContent = this.dataset.title || 'Untitled';
this.createInnerStructure(); }
// this.applyGridPositioning();
}
protected createElement(): HTMLElement {
return document.createElement('swp-event');
}
/**
* Set all-day specific attributes
*/
private setAllDayAttributes(): void {
this.element.dataset.allday = "true";
this.element.dataset.start = this.dateService.toUTC(this.event.start);
this.element.dataset.end = this.dateService.toUTC(this.event.end);
}
/**
* Create inner structure (just text content for all-day events)
*/
private createInnerStructure(): void {
this.element.textContent = this.event.title;
} }
/** /**
* Apply CSS grid positioning * Apply CSS grid positioning
*/ */
public applyGridPositioning(layout: EventLayout): void { public applyGridPositioning(row: number, startColumn: number, endColumn: number): void {
const gridArea = `${layout.row} / ${layout.startColumn} / ${layout.row + 1} / ${layout.endColumn + 1}`; const gridArea = `${row} / ${startColumn} / ${row + 1} / ${endColumn + 1}`;
this.element.style.gridArea = gridArea; this.style.gridArea = gridArea;
} }
/**
* Create from CalendarEvent
*/
public static fromCalendarEvent(event: CalendarEvent): SwpAllDayEventElement {
const element = document.createElement('swp-allday-event') as SwpAllDayEventElement;
const timezone = calendarConfig.getTimezone?.();
const dateService = new DateService(timezone);
element.dataset.eventId = event.id;
element.dataset.title = event.title;
element.dataset.start = dateService.toUTC(event.start);
element.dataset.end = dateService.toUTC(event.end);
element.dataset.type = event.type;
element.dataset.allDay = 'true';
element.textContent = event.title;
return element;
}
} }
// Register custom elements
customElements.define('swp-event', SwpEventElement);
customElements.define('swp-allday-event', SwpAllDayEventElement);

View file

@ -185,12 +185,9 @@ export class DragDropManager {
// Detect current column // Detect current column
this.currentColumnBounds = ColumnDetectionUtils.getColumnBounds(currentPosition); this.currentColumnBounds = ColumnDetectionUtils.getColumnBounds(currentPosition);
// Create SwpEventElement from existing DOM element and clone it // Cast to SwpEventElement and create clone
const originalSwpEvent = SwpEventElement.fromExistingElement(this.draggedElement); const originalSwpEvent = this.draggedElement as SwpEventElement;
const clonedSwpEvent = originalSwpEvent.createClone(); this.draggedClone = originalSwpEvent.createClone();
// Get the cloned DOM element
this.draggedClone = clonedSwpEvent.getElement();
const dragStartPayload: DragStartEventPayload = { const dragStartPayload: DragStartEventPayload = {
draggedElement: this.draggedElement, draggedElement: this.draggedElement,

View file

@ -76,10 +76,10 @@ export class AllDayEventRenderer {
const container = this.getContainer(); const container = this.getContainer();
if (!container) return null; if (!container) return null;
let dayEvent = new SwpAllDayEventElement(event); const dayEvent = SwpAllDayEventElement.fromCalendarEvent(event);
dayEvent.applyGridPositioning(layout); dayEvent.applyGridPositioning(layout.row, layout.startColumn, layout.endColumn);
container.appendChild(dayEvent.getElement()); container.appendChild(dayEvent);
} }

View file

@ -43,86 +43,6 @@ export class DateEventRenderer implements EventRendererStrategy {
} }
/**
* Update clone timestamp based on new position
*/
private updateCloneTimestamp(payload: DragMoveEventPayload): void {
if (payload.draggedClone.dataset.allDay === "true" || !payload.columnBounds) return;
const gridSettings = calendarConfig.getGridSettings();
const { hourHeight, dayStartHour, snapInterval } = gridSettings;
if (!payload.draggedClone.dataset.originalDuration) {
throw new DOMException("missing clone.dataset.originalDuration");
}
// Calculate snapped start minutes
const minutesFromGridStart = (payload.snappedY / hourHeight) * 60;
const snappedStartMinutes = this.calculateSnappedMinutes(
minutesFromGridStart, dayStartHour, snapInterval
);
// Calculate end minutes
const originalDuration = parseInt(payload.draggedClone.dataset.originalDuration);
const endTotalMinutes = snappedStartMinutes + originalDuration;
// Update UI
this.updateTimeDisplay(payload.draggedClone, snappedStartMinutes, endTotalMinutes);
// Update data attributes
this.updateDateTimeAttributes(
payload.draggedClone,
new Date(payload.columnBounds.date),
snappedStartMinutes,
endTotalMinutes
);
}
/**
* Calculate snapped minutes from grid start
*/
private calculateSnappedMinutes(minutesFromGridStart: number, dayStartHour: number, snapInterval: number): number {
const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart;
return Math.round(actualStartMinutes / snapInterval) * snapInterval;
}
/**
* Update time display in the UI
*/
private updateTimeDisplay(element: HTMLElement, startMinutes: number, endMinutes: number): void {
const timeElement = element.querySelector('swp-event-time');
if (!timeElement) return;
const startTime = this.formatTimeFromMinutes(startMinutes);
const endTime = this.formatTimeFromMinutes(endMinutes);
timeElement.textContent = `${startTime} - ${endTime}`;
}
/**
* Update data-start and data-end attributes with ISO timestamps
*/
private updateDateTimeAttributes(element: HTMLElement, columnDate: Date, startMinutes: number, endMinutes: number): void {
const startDate = this.dateService.createDateAtTime(columnDate, startMinutes);
let endDate = this.dateService.createDateAtTime(columnDate, endMinutes);
// Handle cross-midnight events
if (endMinutes >= 1440) {
const extraDays = Math.floor(endMinutes / 1440);
endDate = this.dateService.addDays(endDate, extraDays);
}
// Convert to UTC before storing as ISO string
element.dataset.start = this.dateService.toUTC(startDate);
element.dataset.end = this.dateService.toUTC(endDate);
}
/**
* Format minutes since midnight to time string
*/
private formatTimeFromMinutes(totalMinutes: number): string {
return this.dateService.minutesToTime(totalMinutes);
}
/** /**
* Handle drag start event * Handle drag start event
@ -155,15 +75,12 @@ export class DateEventRenderer implements EventRendererStrategy {
* Handle drag move event * Handle drag move event
*/ */
public handleDragMove(payload: DragMoveEventPayload): void { public handleDragMove(payload: DragMoveEventPayload): void {
if (!this.draggedClone) return; if (!this.draggedClone || !payload.columnBounds) return;
// Update position - snappedY is already the event top position
// Add +1px to match the initial positioning offset from SwpEventElement
this.draggedClone.style.top = (payload.snappedY + 1) + 'px';
// Update timestamp display
this.updateCloneTimestamp(payload);
// Delegate to SwpEventElement to update position and timestamps
const swpEvent = this.draggedClone as SwpEventElement;
const columnDate = new Date(payload.columnBounds.date);
swpEvent.updatePosition(columnDate, payload.snappedY);
} }
/** /**
@ -191,16 +108,9 @@ export class DateEventRenderer implements EventRendererStrategy {
// Recalculate timestamps with new column date // Recalculate timestamps with new column date
const currentTop = parseFloat(this.draggedClone.style.top) || 0; const currentTop = parseFloat(this.draggedClone.style.top) || 0;
const mockPayload: DragMoveEventPayload = { const swpEvent = this.draggedClone as SwpEventElement;
draggedElement: dragColumnChangeEvent.originalElement, const columnDate = new Date(dragColumnChangeEvent.newColumn.date);
draggedClone: this.draggedClone, swpEvent.updatePosition(columnDate, currentTop);
mousePosition: dragColumnChangeEvent.mousePosition,
mouseOffset: { x: 0, y: 0 },
columnBounds: dragColumnChangeEvent.newColumn,
snappedY: currentTop
};
this.updateCloneTimestamp(mockPayload);
} }
} }
@ -272,8 +182,7 @@ export class DateEventRenderer implements EventRendererStrategy {
} }
private renderEvent(event: CalendarEvent): HTMLElement { private renderEvent(event: CalendarEvent): HTMLElement {
const swpEvent = SwpEventElement.fromCalendarEvent(event); return SwpEventElement.fromCalendarEvent(event);
return swpEvent.getElement();
} }
protected calculateEventPosition(event: CalendarEvent): { top: number; height: number } { protected calculateEventPosition(event: CalendarEvent): { top: number; height: number } {

View file

@ -247,8 +247,7 @@ export class EventRenderingService {
// Use SwpEventElement factory to create day event from all-day event // Use SwpEventElement factory to create day event from all-day event
const dayEventElement = SwpEventElement.fromAllDayElement(allDayClone as HTMLElement); const dayElement = SwpEventElement.fromAllDayElement(allDayClone as HTMLElement);
const dayElement = dayEventElement.getElement();
// Remove the all-day clone - it's no longer needed since we're converting to day event // Remove the all-day clone - it's no longer needed since we're converting to day event
allDayClone.remove(); allDayClone.remove();

View file

@ -321,7 +321,7 @@ swp-allday-column {
} }
/* All-day events in containers */ /* All-day events in containers */
swp-allday-container swp-event { swp-allday-container swp-allday-event {
height: 22px !important; /* Fixed height for consistent stacking */ height: 22px !important; /* Fixed height for consistent stacking */
position: relative !important; position: relative !important;
width: auto !important; width: auto !important;
@ -353,7 +353,7 @@ swp-allday-container swp-event {
} }
/* Overflow indicator styling */ /* Overflow indicator styling */
swp-allday-container swp-event.max-event-indicator { swp-allday-container swp-allday-event.max-event-indicator {
background: #e0e0e0 !important; background: #e0e0e0 !important;
color: #666 !important; color: #666 !important;
border: 1px dashed #999 !important; border: 1px dashed #999 !important;
@ -364,13 +364,13 @@ swp-allday-container swp-event.max-event-indicator {
justify-content: center; justify-content: center;
} }
swp-allday-container swp-event.max-event-indicator:hover { swp-allday-container swp-allday-event.max-event-indicator:hover {
background: #d0d0d0 !important; background: #d0d0d0 !important;
color: #333 !important; color: #333 !important;
opacity: 1; opacity: 1;
} }
swp-allday-container swp-event.max-event-indicator span { swp-allday-container swp-allday-event.max-event-indicator span {
display: block; display: block;
width: 100%; width: 100%;
text-align: center; text-align: center;
@ -378,23 +378,23 @@ swp-allday-container swp-event.max-event-indicator span {
font-weight: normal; font-weight: normal;
} }
swp-allday-container swp-event.max-event-overflow-show { swp-allday-container swp-allday-event.max-event-overflow-show {
opacity: 1; opacity: 1;
transition: opacity 0.3s ease-in-out; transition: opacity 0.3s ease-in-out;
} }
swp-allday-container swp-event.max-event-overflow-hide { swp-allday-container swp-allday-event.max-event-overflow-hide {
opacity: 0; opacity: 0;
transition: opacity 0.3s ease-in-out; transition: opacity 0.3s ease-in-out;
} }
/* Hide time element for all-day styled events */ /* Hide time element for all-day styled events */
swp-allday-container swp-event swp-event-time{ swp-allday-container swp-allday-event swp-event-time{
display: none; display: none;
} }
/* Adjust title display for all-day styled events */ /* Adjust title display for all-day styled events */
swp-allday-container swp-event swp-event-title { swp-allday-container swp-allday-event swp-event-title {
display: block; display: block;
font-size: 12px; font-size: 12px;
line-height: 18px; line-height: 18px;