본문 바로가기
HTML

HTML5 <details> 태그를 사용해서 Q&A 만들기

by F.E.D 2023. 10. 9.

IE가 더 이상 서비스하지 않는 브라우저가 되고 나서, 우리는 다양한 HTML5 요소와 CSS를 사용해서 스타일링할 수 있게 되었습니다.

그 중에서 details 요소는 특히, IE에서 전혀 지원하지 않는 요소였는데요.

 

Can I Use(https://caniuse.com/?search=details)에서 확인해보면 더 이상 걱정하지 않아도 될 정도의 서비스 범위를 가지고 있는 것으로 확인됩니다.

 

details 요소를 사용해서 자주 사용하는 Q&A 컴포넌트를 만들어보도록 하겠습니다.

Q&A는 주로 아코디언(Accordion)이라는 UI 컴포넌트로 만들게 됩니다.

 

<section class="accordion">
  <details>
    <summary class="question">Q. 궁금한 것이 있어요.</summary>
    <div class="answer">
      저의 대답은 이렇습니다. 저의 대답은 이렇습니다. <br />
      저의 대답은 이렇습니다. 저의 대답은 이렇습니다. <br />
      저의 대답은 이렇습니다. 저의 대답은 이렇습니다. <br />
    </div>
  </details>
  <details>
    <summary class="question">Q. 궁금한 것이 있어요222.</summary>
    <div class="answer">
      저의 대답은 이렇습니다. 저의 대답은 이렇습니다222. <br />
      저의 대답은 이렇습니다. 저의 대답은 이렇습니다. <br />
      저의 대답은 이렇습니다. 저의 대답은 이렇습니다. <br />
    </div>
    </ul>
  </details>
  <details>
    <summary class="question">Q. 궁금한 것이 있어요333.</summary>
    <div class="answer">
      저의 대답은 이렇습니다. 저의 대답은 이렇습니다333. <br />
      저의 대답은 이렇습니다. 저의 대답은 이렇습니다. <br />
      저의 대답은 이렇습니다. 저의 대답은 이렇습니다. <br />
    </div>
  </details>

</section>

 

HTML만으로도 블로그 문서에서도 이렇게 표현하실 수 있습니다.

아래의 화살표 있는 텍스트를 각각 클릭 해보세요.

 

Q. 궁금한 것이 있어요.
저의 대답은 이렇습니다. 저의 대답은 이렇습니다.
저의 대답은 이렇습니다. 저의 대답은 이렇습니다.
저의 대답은 이렇습니다. 저의 대답은 이렇습니다.
Q. 궁금한 것이 있어요222.
저의 대답은 이렇습니다. 저의 대답은 이렇습니다222.
저의 대답은 이렇습니다. 저의 대답은 이렇습니다.
저의 대답은 이렇습니다. 저의 대답은 이렇습니다.
Q. 궁금한 것이 있어요333.
저의 대답은 이렇습니다. 저의 대답은 이렇습니다333.
저의 대답은 이렇습니다. 저의 대답은 이렇습니다.
저의 대답은 이렇습니다. 저의 대답은 이렇습니다.

 

@charset "utf-8";

:root {
  --left-padding: 35px;
  --arrow-width: 6px;
  --transition-duration: 300ms;
}

.accordion {
  height: 100%;
  padding: 50px 20px;
  color: #fff;
  line-height: 1.5;
}

details {
  width: 100%;
  max-width: 1024px;
  margin: 0 auto 10px;
  background: #c3c3c3;
  border-radius: 10px;
  overflow: hidden;
}

/* 기본 details-marker를 안보이도록 처리 */
details summary::-webkit-details-marker {
  display: block;
}

summary {
  display: block;
  position: relative;
  padding: 15px 15px 15px var(--left-padding);
  background: #222;
  cursor: pointer;
}

summary:before {
  content: "";
  position: absolute;
  top: 50%;
  left: calc(var(--left-padding) / 2);
  margin-top: calc(var(--arrow-width) * -1);
  border-width: var(--arrow-width);
  border-style: solid;
  border-color: transparent transparent transparent #fff;
  transform-origin: calc(var(--arrow-width) / 2) 50%;
  transition: var(--transition-duration) transform ease;
}

details[open] > summary:before {
  transform: rotate(90deg);
}

details > .answer {
  padding: 15px var(--left-padding);
  background-color: #333;
}

CSS에서는 기본 HTML details 요소에서 summary::-webkit-details-marker  display: none;으로 해서 기존 HTML 네이티브 summary의 marker를 안보이도록 하고 ::before 가상 요소 선택자로 새롭게 만든 화살표를 사용합니다.

 

각각의 :root 변수는 화살표의 계산식을 조금 더 간편하게 유동적으로 할당할 수 있도록 도와줍니다.

혹시 CSS로 화살표를 만드는 것이 어렵게 느껴진다면 다음 문서를 참고하세요.

 

https://css-tricks.com/snippets/css/css-triangle/

 

CSS Triangle | CSS-Tricks

You can make them with a single div. It's nice to have classes for each direction possibility.

css-tricks.com

 

그리고 details[open]으로 해당 속성이 열리게 되었을 때 화살표를 90deg로 회전(rotate)만 해주면 됩니다.

 

See the Pen <details> and <summary> with animated arrow (no JS) by 김영민 (@yrbtnzxm-the-selector) on CodePen.

 

 

HTML과 CSS만으로도 이렇게 아코디언 UI를 만들 수 있는데요.

JavaScript를 추가해서 부드럽게 컨텐츠 높이만큼 늘어나도록 할 수 있겠는데요.

 

class QnA {
  constructor($element) {
    this.$element = $element;
    this.$summary = $element.querySelector('.question');
    this.$content = $element.querySelector('.answer');
   
    this.animation = null;  // animation 상태를 초기화
    this.isClosing = false; // 닫힌 상태인지
    this.isExpanding = false; // 열린 상태인지
    // css 변수에서 스타일을 가져오게 됨, 기본적으로 stirng이기 때문에 숫자형으로 변경
    this.CSSVariableTransition = Number(getComputedStyle(document.documentElement) 
    .getPropertyValue('--transition-duration').split('ms')[0]) || 300;
    this.$summary.addEventListener('click', (e) => this.clickEvent(e));
  }

  clickEvent(e) {
    // 브라우저의 기본 동작을 막음
    e.preventDefault();
    /*
     * 만약, $element가 닫혀있거나(isClosing), open 속성이 false라면 oepnEvent로 열기
     * 만약, $element가 열려있거나(isExpanding), open 속성이 true라면 closeEvent로 닫기
     */
    if (this.isClosing || !this.$element.open) {
      this.openEvent();
    } else if (this.isExpanding || this.$element.open) {
      this.closeEvent();
    }
  }

  openEvent() {
    // 유동적으로 height를 조절하면서 부드럽게 열리도록 처리
    this.$element.style.height = `${this.$element.offsetHeight}px`; 
    // open상태를 강제로 true로 변경
    this.$element.open = true;
    // animation을 부여해서 확장하며 열리는 효과를 부여
    window.requestAnimationFrame(() => this.expandEvent());
  }
  
  expandEvent() {
    // isExpanding 상태를 강제로 true로 변경
    this.isExpanding = true;
    // 현재 Height값을 저장
    const currentHeight = `${this.$element.offsetHeight}px`;
    // 현재 열려있는 Height값을 계산
    const expandHeight = `${this.$summary.offsetHeight + this.$content.offsetHeight}px`;
    
    // 이미 애니메이션이 진행중일 때는 취소한다. animation 내장 메서드 cancel 사용
    if (this.animation) {
      this.animation.cancel();
    }
    
    // animate 메서드 사용해서 부드럽게 열리도록 처리
    this.animation = this.$element.animate({
      // 현재 높이, 열려질 높이를 지정
      height: [currentHeight, expandHeight]
    }, {
      // CSS 변수에서 가져온 transition 값 할당
      duration: this.CSSVariableTransition,
      easing: 'ease-out'
    });
    // animation이 끝나게 되면 animation이 끝났다는 메서드 호출
    this.animation.onfinish = () => this.onAnimationFinish(true);
    // animation이 취소 되면 animation이 취소되었으니 isExpanding을 강제로 false로 변경
    this.animation.oncancel = () => this.isExpanding = false;
  }


  closeEvent() {
    this.isClosing = true;
    const currentHeight = `${this.$element.offsetHeight}px`;
    // summary의 높이 만큼 닫히도록 저장
    const closedHeight = `${this.$summary.offsetHeight}px`;
    
    if (this.animation) {
      this.animation.cancel();
    }
    
    this.animation = this.$element.animate({
      height: [currentHeight, closedHeight]
    }, {
      duration: this.CSSVariableTransition,
      easing: 'ease-out'
    });
    
    this.animation.onfinish = () => this.onAnimationFinish(false);
    this.animation.oncancel = () => this.isClosing = false;
  }
  // 애니메이션이 끝나면 초기화
  onAnimationFinish(isOpen) {
    this.$element.open = isOpen;
    this.animation = null;
    this.isClosing = false;
    this.isExpanding = false;
    this.$element.style.height = '';
  }
}

document.querySelectorAll('details').forEach(($element) => {
  new QnA($element);
});

우선, Class 문법을 활용해서 각각의 $element를 정의 해줍니다.($를 붙이는 것은 element를 가리키는 암묵적인 룰입니다.)

각각의 상태를 저장하고, CSS에서 변수를 가져와서 animation 할 때 초의 값으로 지정해줍니다. 

혹시 값이 없을 때를 대비하여 300을 || (OR 연산자)를 사용해서 할당 합니다.

$summary 요소를 클릭하게 되면 clickEvent 메서드가 수행되도록 합니다.

각각의 나머지 메서드의 동작방식은 주석으로 걸어두었으니 참고하시길 바랍니다.

그리고 마지막에 애니메이션이 끝나면 요소들의 상태를 초기화 해줍니다.

그리고 details 요소들을 forEach로 순회하며 인스턴스를 생성해줍니다.

 

See the Pen <details> 과 <summary> 를 사용, JavaScript를 첨가 by 김영민 (@yrbtnzxm-the-selector) on CodePen.

 

위와 같은 아코디언 UI를 만들어보면서 JavaScript를 더욱 즐겁게 공부합시다.

 

 

 

 

출처 : https://css-tricks.com/how-to-animate-the-details-element/

댓글