커스텀 위젯

커스텀 위젯

레이아웃에서 사용할 수 있는 커스텀 위젯을 개발하는 방법을 학습합니다.

위젯 개발 기초

위젯 구조

widgets/
└── my_widget/
    ├── conf/
    │   └── info.xml              # 위젯 정보
    ├── my_widget.class.php       # 위젯 클래스
    ├── tpl/
    │   ├── list.html            # 기본 템플릿
    │   ├── gallery.html         # 갤러리 템플릿
    │   └── config.html          # 설정 템플릿
    ├── css/
    │   └── widget.css           # 스타일시트
    ├── js/
    │   └── widget.js            # JavaScript
    └── lang/
        ├── ko.lang.php          # 한국어 언어팩
        └── en.lang.php          # 영어 언어팩

위젯 정보 파일 (info.xml)

<?xml version="1.0" encoding="UTF-8"?>
<widget version="0.2">
    <!-- 기본 정보 -->
    <title xml:lang="ko">최신글 위젯</title>
    <title xml:lang="en">Latest Posts Widget</title>

    <description xml:lang="ko">게시판의 최신글을 표시하는 위젯입니다.</description>
    <description xml:lang="en">Widget to display latest posts from boards.</description>

    <!-- 버전 및 날짜 -->
    <version>1.2.0</version>
    <date>2024-01-01</date>

    <!-- 제작자 정보 -->
    <author email_address="developer@example.com" link="https://example.com">
        <name xml:lang="ko">개발자</name>
        <name xml:lang="en">Developer</name>
    </author>

    <!-- 설정 변수 -->
    <extra_vars>
        <!-- 게시판 선택 -->
        <var name="mid_list" type="mid_list">
            <title xml:lang="ko">대상 게시판</title>
            <title xml:lang="en">Target Boards</title>
            <description xml:lang="ko">글을 가져올 게시판을 선택하세요</description>
        </var>

        <!-- 출력 개수 -->
        <var name="list_count" type="text" default="5">
            <title xml:lang="ko">출력 개수</title>
            <title xml:lang="en">Number of Items</title>
            <description xml:lang="ko">출력할 글의 개수</description>
        </var>

        <!-- 제목 길이 -->
        <var name="title_cut_size" type="text" default="40">
            <title xml:lang="ko">제목 길이</title>
            <title xml:lang="en">Title Length</title>
            <description xml:lang="ko">제목 표시 길이 (0은 전체)</description>
        </var>

        <!-- 썸네일 사용 -->
        <var name="use_thumbnail" type="select" default="Y">
            <title xml:lang="ko">썸네일 사용</title>
            <title xml:lang="en">Use Thumbnail</title>
            <options value="Y">사용</options>
            <options value="N">사용안함</options>
        </var>

        <!-- 썸네일 크기 -->
        <var name="thumbnail_width" type="text" default="80">
            <title xml:lang="ko">썸네일 너비</title>
            <title xml:lang="en">Thumbnail Width</title>
            <description xml:lang="ko">썸네일 이미지 너비 (px)</description>
        </var>

        <var name="thumbnail_height" type="text" default="80">
            <title xml:lang="ko">썸네일 높이</title>
            <title xml:lang="en">Thumbnail Height</title>
            <description xml:lang="ko">썸네일 이미지 높이 (px)</description>
        </var>

        <!-- 날짜 표시 -->
        <var name="show_date" type="select" default="Y">
            <title xml:lang="ko">날짜 표시</title>
            <title xml:lang="en">Show Date</title>
            <options value="Y">표시</options>
            <options value="N">숨김</options>
        </var>

        <!-- 닉네임 표시 -->
        <var name="show_nickname" type="select" default="N">
            <title xml:lang="ko">닉네임 표시</title>
            <title xml:lang="en">Show Nickname</title>
            <options value="Y">표시</options>
            <options value="N">숨김</options>
        </var>

        <!-- 댓글수 표시 -->
        <var name="show_comment_count" type="select" default="Y">
            <title xml:lang="ko">댓글수 표시</title>
            <title xml:lang="en">Show Comment Count</title>
            <options value="Y">표시</options>
            <options value="N">숨김</options>
        </var>

        <!-- 템플릿 선택 -->
        <var name="template" type="select" default="list">
            <title xml:lang="ko">템플릿</title>
            <title xml:lang="en">Template</title>
            <options value="list">목록형</options>
            <options value="gallery">갤러리형</options>
            <options value="card">카드형</options>
        </var>

        <!-- 캐시 시간 -->
        <var name="cache_time" type="text" default="300">
            <title xml:lang="ko">캐시 시간 (초)</title>
            <title xml:lang="en">Cache Time (seconds)</title>
            <description xml:lang="ko">0으로 설정하면 캐시를 사용하지 않습니다</description>
        </var>
    </extra_vars>
</widget>

위젯 클래스 구현

기본 위젯 클래스

<?php
/**
 * 최신글 위젯
 * @author Developer
 * @version 1.2.0
 */

class my_widget extends WidgetHandler
{
    /**
     * 위젯 실행
     */
    function proc($args)
    {
        // 설정값 검증 및 기본값 설정
        $args = $this->validateArgs($args);

        // 캐시 확인
        if($args->cache_time > 0) {
            $cache_key = $this->getCacheKey($args);
            $cache_data = $this->getCache($cache_key);

            if($cache_data) {
                Context::set('widget_cache', true);
                Context::set('document_list', $cache_data);
                return $this->compileTemplate($args);
            }
        }

        // 게시물 데이터 조회
        $document_list = $this->getDocumentList($args);

        // 썸네일 처리
        if($args->use_thumbnail === 'Y') {
            $document_list = $this->processThumbnails($document_list, $args);
        }

        // 캐시 저장
        if($args->cache_time > 0) {
            $this->setCache($cache_key, $document_list, $args->cache_time);
        }

        // 템플릿 변수 설정
        Context::set('document_list', $document_list);
        Context::set('widget_info', $args);

        // CSS/JS 파일 추가
        $this->addAssets($args);

        // 템플릿 컴파일
        return $this->compileTemplate($args);
    }

    /**
     * 설정값 검증 및 기본값 설정
     */
    private function validateArgs($args)
    {
        // 필수 설정값 확인
        if(!$args->mid_list) {
            $args->mid_list = array();
        } elseif(!is_array($args->mid_list)) {
            $args->mid_list = explode(',', $args->mid_list);
        }

        // 숫자형 설정값 검증
        $args->list_count = max(1, min(50, (int)$args->list_count));
        $args->title_cut_size = max(0, (int)$args->title_cut_size);
        $args->thumbnail_width = max(10, min(500, (int)$args->thumbnail_width));
        $args->thumbnail_height = max(10, min(500, (int)$args->thumbnail_height));
        $args->cache_time = max(0, (int)$args->cache_time);

        // Y/N 설정값 검증
        $yn_options = array('use_thumbnail', 'show_date', 'show_nickname', 'show_comment_count');
        foreach($yn_options as $option) {
            if(!in_array($args->{$option}, array('Y', 'N'))) {
                $args->{$option} = 'Y';
            }
        }

        // 템플릿 검증
        $allowed_templates = array('list', 'gallery', 'card');
        if(!in_array($args->template, $allowed_templates)) {
            $args->template = 'list';
        }

        return $args;
    }

    /**
     * 게시물 목록 조회
     */
    private function getDocumentList($args)
    {
        if(empty($args->mid_list)) {
            return array();
        }

        // 모듈 일련번호 조회
        $module_srls = array();
        $oModuleModel = getModel('module');

        foreach($args->mid_list as $mid) {
            $module_info = $oModuleModel->getModuleInfoByMid($mid);
            if($module_info && $module_info->module === 'board') {
                $module_srls[] = $module_info->module_srl;
            }
        }

        if(empty($module_srls)) {
            return array();
        }

        // 문서 조회 조건 설정
        $obj = new stdClass();
        $obj->module_srl = implode(',', $module_srls);
        $obj->list_count = $args->list_count;
        $obj->sort_index = 'list_order';
        $obj->order_type = 'asc';
        $obj->statusList = array('PUBLIC');

        // 공지사항 제외
        $obj->exclude_notice = 'Y';

        // 문서 조회
        $oDocumentModel = getModel('document');
        $output = $oDocumentModel->getDocumentList($obj);

        if(!$output->toBool() || !$output->data) {
            return array();
        }

        return $output->data;
    }

    /**
     * 썸네일 처리
     */
    private function processThumbnails($document_list, $args)
    {
        foreach($document_list as $document) {
            $thumbnail = $this->generateThumbnail($document, $args->thumbnail_width, $args->thumbnail_height);
            $document->thumbnail = $thumbnail;
        }

        return $document_list;
    }

    /**
     * 썸네일 생성
     */
    private function generateThumbnail($document, $width, $height)
    {
        // 첨부파일에서 이미지 찾기
        $files = $document->getUploadedFiles();
        if($files) {
            foreach($files as $file) {
                if($file->isImage()) {
                    return FileHandler::getThumbnail($file->uploaded_filename, $width, $height);
                }
            }
        }

        // 본문에서 이미지 추출
        $content = $document->get('content');
        if(preg_match('/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $content, $matches)) {
            $image_url = $matches[1];

            // 상대 경로를 절대 경로로 변환
            if(strpos($image_url, 'http') !== 0) {
                $image_url = Context::getRequestUri() . ltrim($image_url, '/');
            }

            return FileHandler::getThumbnail($image_url, $width, $height);
        }

        // 기본 이미지 반환
        return $this->widget_path . 'images/no-image.png';
    }

    /**
     * 캐시 키 생성
     */
    private function getCacheKey($args)
    {
        $key_data = array(
            'mid_list' => $args->mid_list,
            'list_count' => $args->list_count,
            'template' => $args->template,
            'timestamp' => floor(time() / $args->cache_time)
        );

        return 'widget_my_widget_' . md5(serialize($key_data));
    }

    /**
     * 캐시 조회
     */
    private function getCache($cache_key)
    {
        $cache_file = _XE_PATH_ . 'files/cache/widgets/' . $cache_key . '.cache';

        if(file_exists($cache_file)) {
            $cache_data = unserialize(file_get_contents($cache_file));
            return $cache_data;
        }

        return null;
    }

    /**
     * 캐시 저장
     */
    private function setCache($cache_key, $data, $cache_time)
    {
        $cache_dir = _XE_PATH_ . 'files/cache/widgets/';
        if(!is_dir($cache_dir)) {
            FileHandler::makeDir($cache_dir);
        }

        $cache_file = $cache_dir . $cache_key . '.cache';
        file_put_contents($cache_file, serialize($data));
    }

    /**
     * CSS/JS 자원 추가
     */
    private function addAssets($args)
    {
        // CSS 파일 추가
        $css_file = $this->widget_path . 'css/widget.css';
        if(file_exists($css_file)) {
            Context::addCSSFile($css_file);
        }

        // 템플릿별 CSS 추가
        $template_css = $this->widget_path . 'css/' . $args->template . '.css';
        if(file_exists($template_css)) {
            Context::addCSSFile($template_css);
        }

        // JavaScript 파일 추가
        $js_file = $this->widget_path . 'js/widget.js';
        if(file_exists($js_file)) {
            Context::addJSFile($js_file);
        }
    }

    /**
     * 템플릿 컴파일
     */
    private function compileTemplate($args)
    {
        // 템플릿 파일 경로
        $template_file = $args->template . '.html';
        $template_path = $this->widget_path . 'tpl/' . $template_file;

        if(!file_exists($template_path)) {
            $template_file = 'list.html';
        }

        // 템플릿 컴파일
        $widget_path = $this->widget_path;
        $template = new TemplateHandler();

        return $template->compile($widget_path . 'tpl/', $template_file);
    }
}
?>

템플릿 파일

기본 목록 템플릿 (list.html)

<!-- 위젯 CSS 클래스 -->
<div class="widget_my_widget widget_list">
    <!--@if($document_list)-->
    <ul class="document_list">
        <!--@foreach($document_list as $document)-->
        <li class="document_item">
            <!--@if($widget_info->use_thumbnail === 'Y' && $document->thumbnail)-->
            <div class="thumbnail">
                <a href="{getUrl('', 'mid', $document->get('mid'), 'act', 'dispBoardContent', 'document_srl', $document->document_srl)}">
                    <img src="{$document->thumbnail}" alt="{$document->getTitleText()}" 
                         width="{$widget_info->thumbnail_width}" height="{$widget_info->thumbnail_height}" />
                </a>
            </div>
            <!--@end-->

            <div class="content">
                <h3 class="title">
                    <a href="{getUrl('', 'mid', $document->get('mid'), 'act', 'dispBoardContent', 'document_srl', $document->document_srl)}">
                        <!--@if($widget_info->title_cut_size > 0)-->
                        {cut_str($document->getTitleText(), $widget_info->title_cut_size)}
                        <!--@else-->
                        {$document->getTitleText()}
                        <!--@end-->
                    </a>

                    <!--@if($widget_info->show_comment_count === 'Y' && $document->get('comment_count') > 0)-->
                    <span class="comment_count">[{$document->get('comment_count')}]</span>
                    <!--@end-->
                </h3>

                <div class="meta">
                    <!--@if($widget_info->show_nickname === 'Y')-->
                    <span class="author">{$document->getNickName()}</span>
                    <!--@end-->

                    <!--@if($widget_info->show_date === 'Y')-->
                    <span class="date">{zdate($document->get('regdate'), 'm.d')}</span>
                    <!--@end-->
                </div>
            </div>
        </li>
        <!--@end-->
    </ul>
    <!--@else-->
    <p class="no_documents">등록된 게시물이 없습니다.</p>
    <!--@end-->
</div>

갤러리 템플릿 (gallery.html)

<div class="widget_my_widget widget_gallery">
    <!--@if($document_list)-->
    <div class="gallery_grid">
        <!--@foreach($document_list as $document)-->
        <div class="gallery_item">
            <div class="image_container">
                <a href="{getUrl('', 'mid', $document->get('mid'), 'act', 'dispBoardContent', 'document_srl', $document->document_srl)}">
                    <!--@if($document->thumbnail)-->
                    <img src="{$document->thumbnail}" alt="{$document->getTitleText()}" class="thumbnail" />
                    <!--@else-->
                    <div class="no_image">
                        <i class="fa fa-image"></i>
                    </div>
                    <!--@end-->
                </a>

                <!--@if($widget_info->show_comment_count === 'Y' && $document->get('comment_count') > 0)-->
                <div class="overlay">
                    <span class="comment_count">
                        <i class="fa fa-comment"></i>
                        {$document->get('comment_count')}
                    </span>
                </div>
                <!--@end-->
            </div>

            <div class="info">
                <h4 class="title">
                    <a href="{getUrl('', 'mid', $document->get('mid'), 'act', 'dispBoardContent', 'document_srl', $document->document_srl)}">
                        <!--@if($widget_info->title_cut_size > 0)-->
                        {cut_str($document->getTitleText(), $widget_info->title_cut_size)}
                        <!--@else-->
                        {$document->getTitleText()}
                        <!--@end-->
                    </a>
                </h4>

                <!--@if($widget_info->show_date === 'Y' || $widget_info->show_nickname === 'Y')-->
                <div class="meta">
                    <!--@if($widget_info->show_nickname === 'Y')-->
                    <span class="author">{$document->getNickName()}</span>
                    <!--@end-->

                    <!--@if($widget_info->show_date === 'Y')-->
                    <span class="date">{zdate($document->get('regdate'), 'm.d')}</span>
                    <!--@end-->
                </div>
                <!--@end-->
            </div>
        </div>
        <!--@end-->
    </div>
    <!--@else-->
    <p class="no_documents">등록된 게시물이 없습니다.</p>
    <!--@end-->
</div>

카드형 템플릿 (card.html)

<div class="widget_my_widget widget_card">
    <!--@if($document_list)-->
    <div class="card_container">
        <!--@foreach($document_list as $document)-->
        <article class="card_item">
            <!--@if($widget_info->use_thumbnail === 'Y' && $document->thumbnail)-->
            <div class="card_image">
                <a href="{getUrl('', 'mid', $document->get('mid'), 'act', 'dispBoardContent', 'document_srl', $document->document_srl)}">
                    <img src="{$document->thumbnail}" alt="{$document->getTitleText()}" />
                </a>
            </div>
            <!--@end-->

            <div class="card_content">
                <h3 class="card_title">
                    <a href="{getUrl('', 'mid', $document->get('mid'), 'act', 'dispBoardContent', 'document_srl', $document->document_srl)}">
                        <!--@if($widget_info->title_cut_size > 0)-->
                        {cut_str($document->getTitleText(), $widget_info->title_cut_size)}
                        <!--@else-->
                        {$document->getTitleText()}
                        <!--@end-->
                    </a>
                </h3>

                <div class="card_summary">
                    {cut_str($document->getSummary(), 100)}
                </div>

                <div class="card_footer">
                    <!--@if($widget_info->show_nickname === 'Y')-->
                    <span class="author">
                        <i class="fa fa-user"></i>
                        {$document->getNickName()}
                    </span>
                    <!--@end-->

                    <!--@if($widget_info->show_date === 'Y')-->
                    <span class="date">
                        <i class="fa fa-calendar"></i>
                        {zdate($document->get('regdate'), 'Y.m.d')}
                    </span>
                    <!--@end-->

                    <!--@if($widget_info->show_comment_count === 'Y' && $document->get('comment_count') > 0)-->
                    <span class="comments">
                        <i class="fa fa-comment"></i>
                        {$document->get('comment_count')}
                    </span>
                    <!--@end-->
                </div>
            </div>
        </article>
        <!--@end-->
    </div>
    <!--@else-->
    <div class="no_documents">
        <i class="fa fa-inbox"></i>
        <p>등록된 게시물이 없습니다.</p>
    </div>
    <!--@end-->
</div>

CSS 스타일

기본 스타일 (widget.css)

/* 공통 스타일 */
.widget_my_widget {
    margin: 20px 0;
}

.widget_my_widget a {
    text-decoration: none;
    color: #333;
}

.widget_my_widget a:hover {
    color: #007bff;
}

.no_documents {
    text-align: center;
    padding: 40px 20px;
    color: #999;
    font-style: italic;
}

/* 목록형 스타일 */
.widget_list .document_list {
    list-style: none;
    padding: 0;
    margin: 0;
}

.widget_list .document_item {
    display: flex;
    padding: 10px 0;
    border-bottom: 1px solid #eee;
}

.widget_list .document_item:last-child {
    border-bottom: none;
}

.widget_list .thumbnail {
    flex-shrink: 0;
    margin-right: 15px;
}

.widget_list .thumbnail img {
    border-radius: 4px;
    object-fit: cover;
}

.widget_list .content {
    flex: 1;
    min-width: 0;
}

.widget_list .title {
    margin: 0 0 5px 0;
    font-size: 14px;
    font-weight: normal;
    line-height: 1.4;
}

.widget_list .title a {
    display: block;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.widget_list .comment_count {
    color: #007bff;
    font-size: 12px;
    margin-left: 5px;
}

.widget_list .meta {
    font-size: 12px;
    color: #666;
}

.widget_list .meta span {
    margin-right: 10px;
}

/* 갤러리형 스타일 */
.widget_gallery .gallery_grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
    gap: 15px;
}

.widget_gallery .gallery_item {
    border-radius: 8px;
    overflow: hidden;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    transition: transform 0.3s ease;
}

.widget_gallery .gallery_item:hover {
    transform: translateY(-2px);
}

.widget_gallery .image_container {
    position: relative;
    padding-bottom: 75%; /* 4:3 비율 */
    overflow: hidden;
}

.widget_gallery .thumbnail {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.widget_gallery .no_image {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: #f8f9fa;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #dee2e6;
    font-size: 2em;
}

.widget_gallery .overlay {
    position: absolute;
    top: 5px;
    right: 5px;
    background: rgba(0, 0, 0, 0.7);
    color: white;
    padding: 4px 8px;
    border-radius: 12px;
    font-size: 12px;
}

.widget_gallery .info {
    padding: 10px;
}

.widget_gallery .title {
    margin: 0 0 5px 0;
    font-size: 13px;
    font-weight: 500;
    line-height: 1.3;
}

.widget_gallery .title a {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.widget_gallery .meta {
    font-size: 11px;
    color: #999;
}

/* 카드형 스타일 */
.widget_card .card_container {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
    gap: 20px;
}

.widget_card .card_item {
    background: white;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
    transition: all 0.3s ease;
}

.widget_card .card_item:hover {
    transform: translateY(-4px);
    box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
}

.widget_card .card_image {
    position: relative;
    padding-bottom: 56.25%; /* 16:9 비율 */
    overflow: hidden;
}

.widget_card .card_image img {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.widget_card .card_content {
    padding: 20px;
}

.widget_card .card_title {
    margin: 0 0 10px 0;
    font-size: 16px;
    font-weight: 600;
    line-height: 1.4;
}

.widget_card .card_summary {
    color: #666;
    font-size: 14px;
    line-height: 1.5;
    margin-bottom: 15px;
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.widget_card .card_footer {
    display: flex;
    align-items: center;
    gap: 15px;
    font-size: 12px;
    color: #999;
    border-top: 1px solid #f0f0f0;
    padding-top: 15px;
}

.widget_card .card_footer span {
    display: flex;
    align-items: center;
    gap: 4px;
}

/* 반응형 */
@media (max-width: 768px) {
    .widget_gallery .gallery_grid {
        grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
        gap: 10px;
    }

    .widget_card .card_container {
        grid-template-columns: 1fr;
    }

    .widget_list .document_item {
        flex-direction: column;
    }

    .widget_list .thumbnail {
        margin-right: 0;
        margin-bottom: 10px;
        align-self: flex-start;
    }
}

JavaScript 기능

위젯 JavaScript (widget.js)

/**
 * 위젯 JavaScript 기능
 */
(function() {
    'use strict';

    // 위젯 초기화
    function initWidget() {
        // 지연 로딩 설정
        setupLazyLoading();

        // 툴팁 설정
        setupTooltips();

        // 모달 설정
        setupModal();
    }

    // 이미지 지연 로딩
    function setupLazyLoading() {
        if ('IntersectionObserver' in window) {
            const imageObserver = new IntersectionObserver((entries, observer) => {
                entries.forEach(entry => {
                    if (entry.isIntersecting) {
                        const img = entry.target;
                        img.src = img.dataset.src;
                        img.classList.remove('lazy');
                        imageObserver.unobserve(img);
                    }
                });
            });

            document.querySelectorAll('.widget_my_widget img[data-src]').forEach(img => {
                imageObserver.observe(img);
            });
        }
    }

    // 툴팁 설정
    function setupTooltips() {
        const tooltipElements = document.querySelectorAll('.widget_my_widget [data-tooltip]');

        tooltipElements.forEach(element => {
            element.addEventListener('mouseenter', showTooltip);
            element.addEventListener('mouseleave', hideTooltip);
        });
    }

    function showTooltip(e) {
        const element = e.target;
        const tooltipText = element.dataset.tooltip;

        if (!tooltipText) return;

        const tooltip = document.createElement('div');
        tooltip.className = 'widget-tooltip';
        tooltip.textContent = tooltipText;
        tooltip.style.cssText = `
            position: absolute;
            background: rgba(0, 0, 0, 0.8);
            color: white;
            padding: 6px 10px;
            border-radius: 4px;
            font-size: 12px;
            white-space: nowrap;
            z-index: 10000;
            pointer-events: none;
        `;

        document.body.appendChild(tooltip);

        const rect = element.getBoundingClientRect();
        tooltip.style.left = rect.left + 'px';
        tooltip.style.top = (rect.top - tooltip.offsetHeight - 5) + 'px';

        element._tooltip = tooltip;
    }

    function hideTooltip(e) {
        const element = e.target;
        if (element._tooltip) {
            element._tooltip.remove();
            delete element._tooltip;
        }
    }

    // 모달 설정
    function setupModal() {
        document.addEventListener('click', function(e) {
            const modalTrigger = e.target.closest('[data-modal-target]');
            if (modalTrigger) {
                e.preventDefault();
                openModal(modalTrigger.dataset.modalTarget);
            }

            const modalClose = e.target.closest('[data-modal-close]');
            if (modalClose) {
                e.preventDefault();
                closeModal();
            }
        });

        // ESC 키로 모달 닫기
        document.addEventListener('keydown', function(e) {
            if (e.key === 'Escape') {
                closeModal();
            }
        });
    }

    function openModal(targetId) {
        const modal = document.getElementById(targetId);
        if (modal) {
            modal.style.display = 'block';
            document.body.style.overflow = 'hidden';
        }
    }

    function closeModal() {
        const modals = document.querySelectorAll('.widget-modal');
        modals.forEach(modal => {
            modal.style.display = 'none';
        });
        document.body.style.overflow = '';
    }

    // DOM 준비 시 초기화
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initWidget);
    } else {
        initWidget();
    }

    // 전역 함수로 노출
    window.WidgetHelper = {
        refreshWidget: function(widgetId) {
            // 위젯 새로고침 기능
            const widget = document.getElementById(widgetId);
            if (widget) {
                // AJAX로 위젯 데이터 새로고침
                fetch('/widgets/my_widget/refresh.php', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify({
                        widget_id: widgetId
                    })
                })
                .then(response => response.text())
                .then(html => {
                    widget.innerHTML = html;
                    initWidget(); // 새로 로드된 요소들 재초기화
                })
                .catch(error => {
                    console.error('위젯 새로고침 실패:', error);
                });
            }
        }
    };
})();

모범 사례

  1. 캐싱: 데이터 조회 결과를 적절히 캐싱하여 성능 향상
  2. 반응형: 다양한 화면 크기에 대응하는 반응형 디자인
  3. 접근성: 스크린 리더와 키보드 내비게이션 지원
  4. 성능: 이미지 지연 로딩과 효율적인 DOM 조작
  5. 확장성: 템플릿 시스템으로 다양한 디자인 지원

다음 단계

커스텀 위젯을 만들었다면, 위젯 캐싱에서 성능 최적화를 학습하세요.