멋사 프론트엔드 스쿨에서 받은 과제를 어떻게 해결했는지 기록했습니다. 디자이너의 시안을 그대로 코드로 옮기는 실무에 가까운 일은 처음 해봤는데 정말 좋은 경험이었습니다.

1만 시간의 법칙 과제

figma

디자이너가 작성해준 Figma 시안을 보고 반응형 웹을 구현하는 과제입니다. 혼자 구현해보고 멘토님의 피드백을 받아 고쳤는데 그 내용을 기록해보려고 합니다!!

레포지토리

결과물 (Github Pages로 배포됨)

결과물 최종결과

전체적인 구조

HTML

<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="icon" type="image/x-icon" href="./img/favicon.ico" />
    <link rel="stylesheet" href="./css/index.css" />
    <link rel="stylesheet" href="./css/mobile.css" />
    <title>1만 시간의 법칙</title>
  </head>
  <body>
    <header>
      <h1></h1>
    </header>
    <main>
      <section></section>
      <section></section>
      <section></section>
    </main>
    <footer></footer>
    <div class="cheerup-modal__wrapper cheerup-modal__wrapper-js"></div>
    <script src="./js/10000hours.js"></script>
  </body>
</html>
<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="icon" type="image/x-icon" href="./img/favicon.ico" />
    <link rel="stylesheet" href="./css/index.css" />
    <link rel="stylesheet" href="./css/mobile.css" />
    <title>1만 시간의 법칙</title>
  </head>
  <body>
    <header>
      <h1></h1>
    </header>
    <main>
      <section></section>
      <section></section>
      <section></section>
    </main>
    <footer></footer>
    <div class="cheerup-modal__wrapper cheerup-modal__wrapper-js"></div>
    <script src="./js/10000hours.js"></script>
  </body>
</html>

우선 <head> 요소에서 <link rel="icon" type="image/x-icon" href="./img/favicon.ico" />를 이용해 파비콘을 추가하고, 기본 PC 스타일, 모바일 환경을 위한 스타일을 따로 불러줍니다.

<header>에는 <h1>이 들어가고, <body>는 세 개의 <section>들로 나눠줬습니다.

modal 창을 위한 <div> 요소를 <footer> 요소 아래에 배치했습니다. 호준 강사님께서 추천하신 방법입니다.

마지막으로는 스크립트를 불러옵니다.

CSS

전체적으로 스타일을 어떤식으로 구현해야 효율적일지 고민했습니다.

  • 스타일링 수정이 편하도록 property를 적는 순서 컨벤션을 정해서 작성했습니다. 조금 귀찮은 컨벤션이지만 잘 정해서 작성하니 코드를 관리하는데에 큰 도움이 됐습니다.
    1. `position`, `display`, `align`, `visibility`
    2. `width`, `height`
    3. `margin`, `padding`, `border`, `box-sizing`
    4. `text-align`, `font`, `line-height`
    5. `color`, `background-color`
    6. `background-image`
    7. `animation`, `opacity` 등 기타
    1. `position`, `display`, `align`, `visibility`
    2. `width`, `height`
    3. `margin`, `padding`, `border`, `box-sizing`
    4. `text-align`, `font`, `line-height`
    5. `color`, `background-color`
    6. `background-image`
    7. `animation`, `opacity` 등 기타
  • CSS Selector를 어떤 식으로 작성할지 고민했는데, 일단 class 이름은 BEM을 따라서 정하기로 했습니다. 또한 단순히 class 이름만으로 요소를 선택하기 보다는 '어떤 요소에 대한 스타일인지' 추가적인 정보가 필요하다면 요소명을 붙여줬습니다. (예를 들어 .title 보다는 img.title을 사용하는 식)
  • 사용자의 선택에 따라 글씨 크기를 조정할 수 있도록 함과 동시에 유지보수성을 높이기 위해 font-size 등 일부 property는 px이 아닌 rem/em 단위를 사용했습니다.
  • 주요 색상을 CSS 변수로 선언해 추후 편리하게 색상을 변경할 수 있도록 했습니다.
    :root {
      --main-color: #5b2386;
      --second-main-color: #ffff;
      --sub-color: #babcbe;
      --point-color: #fcee21;
      /* 위에서부터 차례로 보라색, 흰색, 회색, 노란색입니다. */
    }
    :root {
      --main-color: #5b2386;
      --second-main-color: #ffff;
      --sub-color: #babcbe;
      --point-color: #fcee21;
      /* 위에서부터 차례로 보라색, 흰색, 회색, 노란색입니다. */
    }

<body> 요소에 공통적인 스타일을 해주며 코드가 시작됩니다.

body {
  font-weight: 400;
  font-family: "GmarketSans";
  color: var(--second-main-color);
  background-color: var(--main-color);
}
body {
  font-weight: 400;
  font-family: "GmarketSans";
  color: var(--second-main-color);
  background-color: var(--main-color);
}
CSS 파일 구조
  • index.css: PC 스타일을 주로 담으며, 모바일용 스타일시트를 제외한 다른 모든 스타일이 모이는 곳입니다.
    • reset.css: User Agent Stylesheet를 리셋합니다.
    • font.css: 웹폰트를 불러옵니다.
    • tools.css: margin: auto;같이 자주 쓰이는 스타일을 class로 묶어둔 스타일시트입니다.
    • general.css: <button>, <input> 요소처럼 컴포넌트 단위로 자주 쓰이는 스타일을 모은 곳입니다.
  • mobile.css: 모바일용 스타일이 담깁니다.

모바일용 스타일 코드는 대부분이 크기를 조절하거나 margin을 바꾸는 코드여서 여기에선 특별한 경우가 아니라면 설명하지 않겠습니다. 실제 코드를 더 자세히 보고싶다면 레포지토리를 확인해주세요!

tools.css
.blind {
  /* 네이버가 사용하는 ir 기법을 따왔습니다. */
  position: absolute;
  clip: rect(0 0 0 0);
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
}

.img-flow-root {
  display: flow-root;
}

.centering {
  margin: 0 auto;
}

.text-centering {
  text-align: center;
}
.blind {
  /* 네이버가 사용하는 ir 기법을 따왔습니다. */
  position: absolute;
  clip: rect(0 0 0 0);
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
}

.img-flow-root {
  display: flow-root;
}

.centering {
  margin: 0 auto;
}

.text-centering {
  text-align: center;
}

<header> 요소

<h1> 요소

구현 결과

header

HTML
<h1 class="title">
  <img
    class="centering img-flow-root"
    srcset="./img/clock_mobile.png 125w, ./img/clock.png 265w"
    sizes="(max-width: 780px) 125px, 265px"
    src="./img/clock.png"
    alt=""
  />
  <img
    class="title__img centering"
    srcset="./img/title_mobile.png 267w, ./img/title.png 564w"
    sizes="(max-width: 780px) 267px, 564px"
    src="./img/title.png"
    alt="1만 시간의 법칙"
  />
</h1>
<h1 class="title">
  <img
    class="centering img-flow-root"
    srcset="./img/clock_mobile.png 125w, ./img/clock.png 265w"
    sizes="(max-width: 780px) 125px, 265px"
    src="./img/clock.png"
    alt=""
  />
  <img
    class="title__img centering"
    srcset="./img/title_mobile.png 267w, ./img/title.png 564w"
    sizes="(max-width: 780px) 267px, 564px"
    src="./img/title.png"
    alt="1만 시간의 법칙"
  />
</h1>

<img> 요소의 srcset, sizes attribute를 이용해 Viewport 크기에 따라 유동적으로 이미지를 로드하도록 구현했습니다.

타이틀 이미지의 alt를 "1만 시간의 법칙 타이틀"로 할지 "1만 시간의 법칙"으로 할지 고민하다가 스크린 리더를 감안해 이렇게 결정했습니다. 배경이 되는 clock.png를 담은 <img> 요소에는 alt를 비워서 스크린 리더가 무시하도록 했습니다.

CSS
/*
  부모인 h1 요소에 position: relative;를 주고
  img.title에 position: absolute;를 줘서
  두 img 요소가 상대적인 위치를 가지고 겹칠 수 있도록 했습니다.
*/

h1.title {
  /*
    position: relative;를 줘서
    stack context를 생성합니다.
  */
  position: relative;
  margin-top: 120px;
}

h1.title .title__img {
  position: absolute;
  /*
    position: absolute;는 요소를 page flow에서 벗어나게 하기 때문에
    Inline element인 img 요소임에도 불구하고
    Block-level element로서 작동합니다.
  */

  top: 76.5px;
  left: 0;
  right: 0;
  bottom: 0;
  width: 564px;
  height: 112px;
}
/*
  부모인 h1 요소에 position: relative;를 주고
  img.title에 position: absolute;를 줘서
  두 img 요소가 상대적인 위치를 가지고 겹칠 수 있도록 했습니다.
*/

h1.title {
  /*
    position: relative;를 줘서
    stack context를 생성합니다.
  */
  position: relative;
  margin-top: 120px;
}

h1.title .title__img {
  position: absolute;
  /*
    position: absolute;는 요소를 page flow에서 벗어나게 하기 때문에
    Inline element인 img 요소임에도 불구하고
    Block-level element로서 작동합니다.
  */

  top: 76.5px;
  left: 0;
  right: 0;
  bottom: 0;
  width: 564px;
  height: 112px;
}

중첩은 이렇게 처리하고, 이미지의 가운데 정렬은 .centering { margin: 0 auto; }로 줬습니다. 타이틀 아래(under)에 위치하는 시계 이미지에 .img-flow-root { display: flow-root }를 줘서 Inline element로서 baseline에 영향받는 부분을 없앰과 동시에 .centering 스타일이 유효할 수 있도록 해줬고, 로고에 해당하는 .title__imgposition: absolute;.centering을 줘서 정렬했습니다.

이미지에 position: absolute;를 주면 display property는 자동으로 block으로 바뀝니다! 그래서 별다른 스타일을 추가로 주지 않아도 .centering에 영향받아 가운데 정렬이 가능한거죠.

멘토님 Feedback

A.


  • srcset, sizes 사용하신 것과 alt를 "1만 시간의 법칙"로 설정하신 것 잘하셨습니다!
  • "1만 시간의 법칙" 타이틀 이미지 뒤에 배치되는 시계 이미지는 img 태그가 아닌 h1의 background로 넣거나 가상 요소를 통해 넣어도 될 것 같습니다. 시계 이미지가 어떤 정보를 주기 위한 요소라기 보단 디자인(스타일)적인 요소로 존재하는 걸로 보이기 때문입니다 :)

/>


Q. 수업중에 호준님께서는 정적이지 않은 요소라고 보셨는데 (로고는 때에 따라 바꿀 수 있다고 생각하신 것 같습니다) 그런 경우에도 background-image로 넣어도 될까요?



/> A.


대표님이 말씀하신 것처럼 img 태그와 background 속성을 사용하는 기준을 이 이미지 요소가 동적이냐 정적이냐로 나눌 수도 있습니다.


저는 정적, 동적도 고려하지만 이 이미지가 사용자에게 꼭 필요한 정보인가? 디자인적인 요소는 아닌가?를 더 고려해서 작업하는 편입니다.


이 부분은 개인에 따라서 다를 수 있겠지만 저는 사용자에게 필요한 정보는 "1만 시간의 법칙" 타이틀 이미지이고, 겹쳐진 시계 이미지는 정보를 준다기보다는 디자인적인 요소로 판단하여 말씀드렸습니다. ㅎㅎ


(타이틀 이미지 -> img 태그 / 시계 이미지 -> background)

요약하면 로고 뒤의 시계 이미지를 background-image로 표현할지 제가 원래 했던 것처럼 <img> 요소를 중첩시켜 표현할지의 문제였습니다.

  • 이호준 강사님: 로고 전체가 백엔드에서 조작이 가능한 동적인 요소로 만들어야 한다고 판단한다면 <img> 요소로 넣어야 한다는 의견
  • 김유진 멘토님: 사용자에게 필요하지 않은 정보로 보이는 부분은 background-image로 판단한다는 의견

개인적인 판단에는 로고가 바뀐다면 배경 이미지도 동시에 바뀔거라고 생각해 일단은 그대로 두었습니다. 실무였다면 디자인/PM과 의논해 해결할 수 있는 부분이라고 생각합니다.

아예 로고/배경을 두 파일로 나눠 넣지 말고, 배경 이미지까지 하나의 이미지로 합쳐서 로고로 넣는것도 깔끔한 해결책이 아닐까 생각합니다.

<main> 요소

격언 <section>

구현 결과

quote

HTML
<section class="quote-and-dfn">
  <h2 class="blind">격언과 1만 시간의 법칙 정의</h2>
  <blockquote class="quote-wisdom text-centering">
    "연습은 어제의 당신보다 당신을 더 낫게 만든다."
  </blockquote>
  <p class="rule-definition text-centering centering">
    <dfn>1만 시간의 법칙</dfn>은<br />어떤 분야의 전문가가 되기 위해서는<br />
    최소한 1만 시간의 훈련이 필요하다는 법칙이다.
  </p>
</section>
<section class="quote-and-dfn">
  <h2 class="blind">격언과 1만 시간의 법칙 정의</h2>
  <blockquote class="quote-wisdom text-centering">
    "연습은 어제의 당신보다 당신을 더 낫게 만든다."
  </blockquote>
  <p class="rule-definition text-centering centering">
    <dfn>1만 시간의 법칙</dfn>은<br />어떤 분야의 전문가가 되기 위해서는<br />
    최소한 1만 시간의 훈련이 필요하다는 법칙이다.
  </p>
</section>

우선 격언과 정의 부분을 하나의 섹션으로 봤습니다. 시멘틱을 위해 <h2>요소를 넣고 .blind 클래스로 가려줬습니다.

격언은 <blockquote> 요소로 처리했습니다.

<p>요소 안에서는 1만 시간의 법칙을 설명하고 있으므로 <dfn> 요소를 한번 사용해봤습니다. 줄바꿈을 어떻게 처리하는게 좋을지 고민하다가 <br> 요소를 썼지만, 스타일을 통해 구현하는것도 좋은 방법이 될 것 같습니다. (그래서 아래에서 다른 방법을 사용해보기도 합니다.)

CSS
/*
  격언
*/

.quote-wisdom {
  font-size: 2.25rem;
  font-weight: 700;
  font-family: "OTEnjoystoriesBA";
  line-height: 2.5rem;
  color: var(--point-color);
}

/*
  1만 시간의 법칙 정의
*/

p.rule-definition {
  width: 493px;
  height: 85px;
  margin-top: 78px;
  font-size: 1.125rem;
  line-height: 1.9375rem;
  background-image: url("../img/quotes.png");
  background-repeat: no-repeat;
  background-position: center top 13px;
  background-size: contain;
}

p.rule-definition dfn {
  font-size: 1.5rem;
  line-height: 1.5rem;
  font-weight: bold;
}
/*
  격언
*/

.quote-wisdom {
  font-size: 2.25rem;
  font-weight: 700;
  font-family: "OTEnjoystoriesBA";
  line-height: 2.5rem;
  color: var(--point-color);
}

/*
  1만 시간의 법칙 정의
*/

p.rule-definition {
  width: 493px;
  height: 85px;
  margin-top: 78px;
  font-size: 1.125rem;
  line-height: 1.9375rem;
  background-image: url("../img/quotes.png");
  background-repeat: no-repeat;
  background-position: center top 13px;
  background-size: contain;
}

p.rule-definition dfn {
  font-size: 1.5rem;
  line-height: 1.5rem;
  font-weight: bold;
}

.quote-wisdom의 경우 수업중에는 폰트 로드 시간 문제로 많이 쓰이지 않는 폰트이니 이미지로 처리하는 것을 고려해봐야 한다 해주셨지만 우선은 폰트를 로드해 Text node로 구현했습니다. modal에서도 쓰이는 서체이므로 앞으로 더 사용될 수 있는 폰트라고 봤습니다.

<blockquote>는 Block-level element이므로 .text-centering { text-align: center; }를 줘서 Text node의 가운데 정렬을 구현했습니다.

p.rule-definition에서는 큰따옴표 이미지를 background-image로 표현했습니다.

입력 <section>

구현 결과

form

HTML
<section class="rule-form">
  <h2 class="blind">어떤 전문가가 되고 싶고 하루에 몇 시간씩 훈련할 예정인지 입력할 폼</h2>
  <form class="rule-form text-centering rule-form-js" action="">
    <p class="rule-form__txt">
      나는<input
        class="rule-form__input input-main-colored rule-form__input-target-js"
        type="text"
        placeholder="예)프로그래밍"
        required
      />전문가가 될 것이다.
    </p>
    <p class="rule-form__txt">
      <span>그래서 앞으로 매일 하루에</span
      ><input
        class="rule-form__input input-main-colored rule-form__input-hours-js"
        type="text"
        placeholder="예)5시간"
        pattern="[0-9]*\.?[0-9]*.*"
        required
      />시간씩 훈련할 것이다.
    </p>
    <div class="rule-form__btn-wrapper">
      <button class="btn-point-colored btn-squared-mobile">
        <span>나는 며칠 동안 훈련을 해야</span> 1만 시간이 될까?
      </button>
      <img
        class="click-icon"
        srcset="./img/click_mobile.png 43w, ./img/click.png 64w"
        sizes="(max-width: 780px) 43px, 64px"
        src="./img/click.png"
        alt=""
      />
    </div>
  </form>
</section>
<section class="rule-form">
  <h2 class="blind">어떤 전문가가 되고 싶고 하루에 몇 시간씩 훈련할 예정인지 입력할 폼</h2>
  <form class="rule-form text-centering rule-form-js" action="">
    <p class="rule-form__txt">
      나는<input
        class="rule-form__input input-main-colored rule-form__input-target-js"
        type="text"
        placeholder="예)프로그래밍"
        required
      />전문가가 될 것이다.
    </p>
    <p class="rule-form__txt">
      <span>그래서 앞으로 매일 하루에</span
      ><input
        class="rule-form__input input-main-colored rule-form__input-hours-js"
        type="text"
        placeholder="예)5시간"
        pattern="[0-9]*\.?[0-9]*.*"
        required
      />시간씩 훈련할 것이다.
    </p>
    <div class="rule-form__btn-wrapper">
      <button class="btn-point-colored btn-squared-mobile">
        <span>나는 며칠 동안 훈련을 해야</span> 1만 시간이 될까?
      </button>
      <img
        class="click-icon"
        srcset="./img/click_mobile.png 43w, ./img/click.png 64w"
        sizes="(max-width: 780px) 43px, 64px"
        src="./img/click.png"
        alt=""
      />
    </div>
  </form>
</section>

우선 두 <input> 요소들은 각각 한 줄씩 그 주위 텍스트들과 함께 <p> 요소로 묶었습니다. 둘 다 required attribute를 부여해서 반드시 입력해야 하게끔 유효성을 검사합니다.

몇 시간씩 훈련할지 입력하는 두번째 <input> 요소에는 pattern="[0-9]*\.?[0-9]*.*" attribute를 넣어서 시간이 제대로 입력됐는지 정규표현식으로 유효성을 확인하도록 했습니다.

모바일에서의 개행을 고려해 특정 Text node들은 <span>으로 묶었습니다.

노란 입력 제출 버튼은 페이지 내 로직의 작동을 일으키는 역할을 하므로 <button> 요소를 사용했습니다. (<button>의 default typesubmit이므로 타입을 따로 지정하지는 않았습니다.) 이 부분에서 손가락 이미지를 어떻게 구현할까 고민하다가 <button><img>를 감싸는 <div>를 추가해 손가락 <img>position: absolute;를 부여, 위치를 조정하고자 했습니다.

CSS
section.rule-form {
  margin-top: 78px;
}

form.rule-form {
  font-size: 1.5em;
  font-weight: 400;
}

.rule-form__txt + .rule-form__txt {
  margin-top: 27px;
  /*
    추후 입력 줄이 추가되더라도
    .rule-form__txt끼리는 27px의 간격이 자동으로 생깁니다.
  */
}

.rule-form__input {
  /*
    input 요소를 사용할 때 스타일을 재사용이 가능하도록
    나머지 스타일링은 general.css에 넣었습니다.
  */
  margin: 0 17px;
}

/*
  입력 form 바로 밑의 클릭 아이콘과 버튼
*/

.rule-form__btn-wrapper {
  margin-top: 115px;
}

.rule-form__btn-wrapper .click-icon {
  position: absolute;
  margin-left: 7px;
  margin-top: 14px;
}
section.rule-form {
  margin-top: 78px;
}

form.rule-form {
  font-size: 1.5em;
  font-weight: 400;
}

.rule-form__txt + .rule-form__txt {
  margin-top: 27px;
  /*
    추후 입력 줄이 추가되더라도
    .rule-form__txt끼리는 27px의 간격이 자동으로 생깁니다.
  */
}

.rule-form__input {
  /*
    input 요소를 사용할 때 스타일을 재사용이 가능하도록
    나머지 스타일링은 general.css에 넣었습니다.
  */
  margin: 0 17px;
}

/*
  입력 form 바로 밑의 클릭 아이콘과 버튼
*/

.rule-form__btn-wrapper {
  margin-top: 115px;
}

.rule-form__btn-wrapper .click-icon {
  position: absolute;
  margin-left: 7px;
  margin-top: 14px;
}

CSS에서 복잡한 부분은 없었지만, .rule-form__btn.click-icon의 위치를 이렇게 표현하는게 과연 맞는 선택일까 고민이 됐습니다.

두가지 종류의 interactive element style
/*
  모든 버튼 요소
  흰색과 노란색, 모바일 박스형 버튼
*/

a.btn-second-main-colored,
a.btn-point-colored {
  /*
    anchor 요소일 경우 inline-block으로 display value를 바꿔서 width와 height 등 크기와 관련된 property를 변경할 수 있도록 합니다.
  */
  display: inline-block;
}

.btn-second-main-colored,
.btn-point-colored {
  /*
    max-width를 정해서 안정성을 높일까 고민했으나
    현 단계에서는 불필요하다고 생각해 이 상태로 뒀습니다.
  */
  padding: 21px 49px;
  border: none;
  /*
    border-radius 값은 충분히 큰 값을 줘서
    요소 안의 컨텐츠가 많아지더라도 둥근 모양이 유지되도록 했습니다.
  */
  border-radius: 500px;
  font-size: 1.5rem;
  font-weight: bold;
  font-family: "GmarketSans";
  color: var(--main-color);
  background-color: var(--point-color);
  -webkit-box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.5);
  box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.5);
}

.btn-second-main-colored {
  background-color: var(--second-main-color);
}
/*
  모든 버튼 요소
  흰색과 노란색, 모바일 박스형 버튼
*/

a.btn-second-main-colored,
a.btn-point-colored {
  /*
    anchor 요소일 경우 inline-block으로 display value를 바꿔서 width와 height 등 크기와 관련된 property를 변경할 수 있도록 합니다.
  */
  display: inline-block;
}

.btn-second-main-colored,
.btn-point-colored {
  /*
    max-width를 정해서 안정성을 높일까 고민했으나
    현 단계에서는 불필요하다고 생각해 이 상태로 뒀습니다.
  */
  padding: 21px 49px;
  border: none;
  /*
    border-radius 값은 충분히 큰 값을 줘서
    요소 안의 컨텐츠가 많아지더라도 둥근 모양이 유지되도록 했습니다.
  */
  border-radius: 500px;
  font-size: 1.5rem;
  font-weight: bold;
  font-family: "GmarketSans";
  color: var(--main-color);
  background-color: var(--point-color);
  -webkit-box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.5);
  box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.5);
}

.btn-second-main-colored {
  background-color: var(--second-main-color);
}

앞서 루트 요소에 색을 CSS 변수로서 선언한 점을 감안해 클래스명을 지었습니다.

CSS (Mobile)

form_mobile

개행이 일어나는 부분 처리

@media (max-width: 780px) {
  .rule-form__txt + .rule-form__txt span {
    /*
      "그래서 앞으로 매일 하루에"
      부분을 <span> 요소로 묶어서
      모바일 환경에서는 display: block;을 주어 줄바꿈을 구현했습니다.
    */
    display: block;
    margin-bottom: 19px;
  }
}
@media (max-width: 780px) {
  .rule-form__txt + .rule-form__txt span {
    /*
      "그래서 앞으로 매일 하루에"
      부분을 <span> 요소로 묶어서
      모바일 환경에서는 display: block;을 주어 줄바꿈을 구현했습니다.
    */
    display: block;
    margin-bottom: 19px;
  }
}

앞서서는 <br> 요소로 개행을 구현했는데, <span>으로 묶은 요소에 display: block;을 줘 개행하는것도 좋은 방법인 것 같습니다.

interactive elements들 스타일

@media (max-width: 780px) {
  .btn-second-main-colored,
  .btn-point-colored {
    padding: 13.5px 17.5px 12.5px 17.5px;
    font-size: 0.875rem;
  }
  .btn-squared-mobile {
    border-radius: 13px;
    padding: 19px 36.5px 17px 36.5px;
    line-height: 1rem;
  }
  .btn-squared-mobile span {
    display: block;
    line-height: inherit;
  }
}
@media (max-width: 780px) {
  .btn-second-main-colored,
  .btn-point-colored {
    padding: 13.5px 17.5px 12.5px 17.5px;
    font-size: 0.875rem;
  }
  .btn-squared-mobile {
    border-radius: 13px;
    padding: 19px 36.5px 17px 36.5px;
    line-height: 1rem;
  }
  .btn-squared-mobile span {
    display: block;
    line-height: inherit;
  }
}

모바일에서는 둥글둥글했던 interactive element가 네모난 모양으로 바뀌는데, 모바일용 스타일이 적용되는 클래스를 따로 정의해줬습니다. mobile.css가 더 나중에 로드되도록 <link> 요소를 배치하고 클래스를 미리 부여해서 break point를 넘어서면 모바일의 스타일이 적용됩니다.

break point를 780px로 잡은 이유는 PC 화면에서 가장 width가 긴 부분이 약 760px정도 됐기 때문입니다.

멘토님 Feedback

A.


  • 각 input에 label이 없습니다.

  • 두번째 input은 type을 number가 아닌 text를 사용하신 이유가 있을까요? 숫자만 입력되도록 수정하시거나 숫자가 아닌 다른 값이 입력되면 제출되지 않도록 하는 작업이 추가로 필요해보입니다. 현재는 문자를 입력할 경우 오류가 발생합니다.("5시간"과 같이 숫자가 앞으로 오는 경우는 됨.)

  • 클릭 아이콘을 absoulte를 주기 위해서 div를 사용하신거라면 css에서 버튼의 가상요소로 넣는 방법도 고려해 보시면 좋을 것 같네요.


Q. label 요소로 넣을만한 내용이 무엇일지 고민되는데 label을 .blind 클래스 스타일로 가릴경우 스크린리더가 읽을 때 이상하게 들릴 것 같은데 괜찮을까요?!


/>


A. 흠 확실히 스크린리더가 읽을 때 좀 이상하겠네요..^_ㅠ 각 p태그의 첫번째 자식으로 label을 넣어도 될 것 같긴하지만,,이것도 좀 이상하게 들릴 수도 있을 것 같아요..! 이 피드백은 참고만 해주세요..ㅎㅎ

우선 <label>을 어떻게 넣을 것이냐...는 굉장히 까다로운 문제인 것 같습니다. 이렇게 <label>을 무엇으로 지정할지 명확하지 않은 상황에서는 어떤 방법이 좋을지 차차 고민해 봐야겠습니다. 우선은 스크린 리더로 테스트 해본 결과 (개인적인 생각에는) 그대로 가도 문제 없어보여서 건들지는 않았습니다.

두 번째 <input>typetext로 한 이유는 예시가 '5시간' 이였기 때문입니다. 정규표현식으로 소수를 포함한 숫자만 빼오고 뒤에 위치하는 '시간' 텍스트는 무시하도록 했는데, 유진 멘토님께서 '시간5'처럼 텍스트를 앞에 쓸 경우엔 오류가 발생함을 짚어주셨습니다. 정규표현식은 공부를 더 열심히 해봐야겠습니다.

.click-icon 부분은 구현을 너무 더럽게 한게 아닐까 고민이었는데 멘토님께서 딱 집어 좋은 대처방안을 제시해주셨습니다.

Resolve
  • 조언 주신대로 두 번째 <input>typenumber로 변경했습니다.

    <p class="rule-form__txt">
      <span>그래서 앞으로 매일 하루에</span
      ><input
        class="rule-form__input input-main-colored rule-form__input-hours-js"
        type="number"
        placeholder="예)5시간"
        required
      />시간씩 훈련할 것이다.
    </p>
    <p class="rule-form__txt">
      <span>그래서 앞으로 매일 하루에</span
      ><input
        class="rule-form__input input-main-colored rule-form__input-hours-js"
        type="number"
        placeholder="예)5시간"
        required
      />시간씩 훈련할 것이다.
    </p>
  • 클릭 아이콘을 CSS의 pseudo element로 변경했습니다.

    <button class="rule-form__btn btn-point-colored btn-squared-mobile">
      <span>나는 며칠 동안 훈련을 해야</span> 1만 시간이 될까?
    </button>
    <!-- img 요소 삭제 -->
    <!-- 손가락 모양은 CSS 가상 요소로 넣습니다. -->
    <button class="rule-form__btn btn-point-colored btn-squared-mobile">
      <span>나는 며칠 동안 훈련을 해야</span> 1만 시간이 될까?
    </button>
    <!-- img 요소 삭제 -->
    <!-- 손가락 모양은 CSS 가상 요소로 넣습니다. -->
    .rule-form__btn {
      margin-top: 115px;
      position: relative;
    }
    
    .rule-form__btn::after {
      content: url(../img/click.png);
      position: absolute;
      left: calc(100% + 7px);
      top: 14px;
    }
    .rule-form__btn {
      margin-top: 115px;
      position: relative;
    }
    
    .rule-form__btn::after {
      content: url(../img/click.png);
      position: absolute;
      left: calc(100% + 7px);
      top: 14px;
    }

결과 확인 <section>

구현 결과

result

HTML
<section class="result text-centering">
  <!-- text-align은 inharitable property입니다. -->
  <h2 class="blind">결과 확인</h2>
  <p class="result__txt centering">
    당신은<strong class="result__target-js">프로그래밍</strong>전문가가 되기 위해서
  </p>
  <p class="result__txt centering">
    대략<strong class="result__days-js">5110</strong>일 이상 훈련하셔야 합니다! :)
  </p>
  <div>
    <button class="btn-point-colored cheerup-modal__opener-js" type="button">
      훈련하러 가기 GO!GO!</button
    ><button
      class="btn-second-main-colored share-link share-link-js"
      data-copiedlink="https://custardcream98.github.io/the-10000-hours-rule/"
      type="button"
    >
      공유하기
    </button>
  </div>
</section>
<section class="result text-centering">
  <!-- text-align은 inharitable property입니다. -->
  <h2 class="blind">결과 확인</h2>
  <p class="result__txt centering">
    당신은<strong class="result__target-js">프로그래밍</strong>전문가가 되기 위해서
  </p>
  <p class="result__txt centering">
    대략<strong class="result__days-js">5110</strong>일 이상 훈련하셔야 합니다! :)
  </p>
  <div>
    <button class="btn-point-colored cheerup-modal__opener-js" type="button">
      훈련하러 가기 GO!GO!</button
    ><button
      class="btn-second-main-colored share-link share-link-js"
      data-copiedlink="https://custardcream98.github.io/the-10000-hours-rule/"
      type="button"
    >
      공유하기
    </button>
  </div>
</section>

<button><a> 모두 Inline elements이므로 text-align: center;를 이용해 가운데 정렬하기 위한 <div> 요소를 추가했습니다. <button>들의 좌 우측에 각각 margin:auto;를 주는것도 방법이 될 수 있겠지만 이게 더 간편하다고 생각했습니다. 과제를 하면서 이렇게 CSS를 줄일지, 마크업을 줄일지 고민이 되는 부분이 많았습니다.

"훈련하러 가기"는 modal 창을 띄우고, "공유하기"는 페이지의 URL을 복사하는 <button> 요소로 봤습니다.

"공유하기" <button>에서는 data-copiedlink라는 data-* attribute를 추가해 URL을 넘기도록 했습니다. 이걸 JS에서 받아서 사용할겁니다.

CSS
section.result {
  margin-top: 147px;
  margin-bottom: 130px;
  font-size: 1.5rem;
  line-height: 2;
  /* line-height를 크게 줘서 결과값이 result__txt의 max-width보다 커질 때 각 줄간의 공백을 줍니다. */
}

.result__txt {
  max-width: 765px;
}

.result__txt + .result__txt {
  margin-top: 17px;
}

.result__txt strong {
  display: inline-block;
  margin: 0px 12px;
  font-size: 4.5rem;
  font-weight: bold;
  font-size: 4.5rem;
  line-height: 1;

  /* 
    최대한 가운데에 가까운 값
  */
  vertical-align: -0.235em;
}

section.result div:last-child {
  /*
    last-child pesudo class를 이용해
    결과 내용이 늘어나더라도 항상
    아래의 interactive elements들을 감싼 div 요소와 결과 내용들이
    일정한 간격을 유지하도록 했습니다.
  */
  margin-top: 127px;
}

.share-link {
  margin-left: 18px;
}
section.result {
  margin-top: 147px;
  margin-bottom: 130px;
  font-size: 1.5rem;
  line-height: 2;
  /* line-height를 크게 줘서 결과값이 result__txt의 max-width보다 커질 때 각 줄간의 공백을 줍니다. */
}

.result__txt {
  max-width: 765px;
}

.result__txt + .result__txt {
  margin-top: 17px;
}

.result__txt strong {
  display: inline-block;
  margin: 0px 12px;
  font-size: 4.5rem;
  font-weight: bold;
  font-size: 4.5rem;
  line-height: 1;

  /* 
    최대한 가운데에 가까운 값
  */
  vertical-align: -0.235em;
}

section.result div:last-child {
  /*
    last-child pesudo class를 이용해
    결과 내용이 늘어나더라도 항상
    아래의 interactive elements들을 감싼 div 요소와 결과 내용들이
    일정한 간격을 유지하도록 했습니다.
  */
  margin-top: 127px;
}

.share-link {
  margin-left: 18px;
}

section.result에서 line-height property의 값을 크게 줘서 사용자가 긴 목표를 입력했을 경우에도 적당한 줄간격을 가지도록 한 부분이 포인트입니다. .result__txtmax-width를 준 부분도 눈여겨 볼만 합니다.

멘토님 Feedback

A.


  • 버튼 정렬을 div로 묶어서 작업하신 것 잘하셨습니다! 저도 그렇게 묶었을 거에요! ㅎㅎ 편한 방법 사용하십쇼

🙌🙌 좋은 선택이었던 것 같아요ㅎㅎ

위니브 로고와 저작권 정보 부분

구현 결과

footer

HTML
<footer>
  <img
    class="weniv-logo centering img-flow-root"
    srcset="./img/logo_mobile.png 91w, ./img/logo.png 125w"
    sizes="(max-width: 780px) 91px, 125px"
    src="./img/logo.png"
    alt="위니브"
  />
  <small class="txt-footer"
    >※ 본 서비스 내 이미지 및 콘텐츠의 저작권은 주식회사 WeNiv에 있습니다.<br />
    수정 및 재배포, 무단 도용 시 법적인 문제가 발생할 수 있습니다.</small
  >
  <!-- 저작권과 관련된 내용이므로 <small> 요소를 사용했습니다. -->
</footer>
<footer>
  <img
    class="weniv-logo centering img-flow-root"
    srcset="./img/logo_mobile.png 91w, ./img/logo.png 125w"
    sizes="(max-width: 780px) 91px, 125px"
    src="./img/logo.png"
    alt="위니브"
  />
  <small class="txt-footer"
    >※ 본 서비스 내 이미지 및 콘텐츠의 저작권은 주식회사 WeNiv에 있습니다.<br />
    수정 및 재배포, 무단 도용 시 법적인 문제가 발생할 수 있습니다.</small
  >
  <!-- 저작권과 관련된 내용이므로 <small> 요소를 사용했습니다. -->
</footer>
CSS
footer small.txt-footer {
  display: block;
  /*
    Block-level element로 바꿔서
    text-align: center;를 줘 텍스트를 가운데 정렬합니다.
  */
  margin-top: 20px;
  margin-bottom: 70px;
  text-align: center;
  font-size: 0.75rem;
  font-family: "Noto Sans KR";
  line-height: 1.0625rem;
}
footer small.txt-footer {
  display: block;
  /*
    Block-level element로 바꿔서
    text-align: center;를 줘 텍스트를 가운데 정렬합니다.
  */
  margin-top: 20px;
  margin-bottom: 70px;
  text-align: center;
  font-size: 0.75rem;
  font-family: "Noto Sans KR";
  line-height: 1.0625rem;
}
구현 결과

modal

뒷 배경에 블러처리를 해줬습니다!

HTML
<div class="cheerup-modal__wrapper cheerup-modal__wrapper-js">
  <!--
        배경 blur를 위해서
        wrapper 역할의 div 요소로 감쌌습니다.
      -->
  <section class="cheerup-modal cheerup-modal-js">
    <h2 class="blind">응원 팝업창</h2>
    <!-- 정확한 명칭은 modal창이지만 듣는이를 고려했습니다. -->
    <p class="cheerup-modal__txt">
      <strong>화이팅!!<span class="hide-mobile">♥♥♥</span></strong>
      <span>당신의 꿈을 응원합니다!</span>
    </p>
    <img
      class="img-flow-root centering cheerup-modal__img"
      src="./img/licat.png"
      alt="응원하는 라이캣"
    /><a
      class="btn-point-colored centering btn-squared-mobile"
      href="https://paullab.co.kr/index.html"
      ><span>종료하고 진짜</span> <span>훈련하러 가기 GO!GO!</span></a
    >
  </section>
</div>
<div class="cheerup-modal__wrapper cheerup-modal__wrapper-js">
  <!--
        배경 blur를 위해서
        wrapper 역할의 div 요소로 감쌌습니다.
      -->
  <section class="cheerup-modal cheerup-modal-js">
    <h2 class="blind">응원 팝업창</h2>
    <!-- 정확한 명칭은 modal창이지만 듣는이를 고려했습니다. -->
    <p class="cheerup-modal__txt">
      <strong>화이팅!!<span class="hide-mobile">♥♥♥</span></strong>
      <span>당신의 꿈을 응원합니다!</span>
    </p>
    <img
      class="img-flow-root centering cheerup-modal__img"
      src="./img/licat.png"
      alt="응원하는 라이캣"
    /><a
      class="btn-point-colored centering btn-squared-mobile"
      href="https://paullab.co.kr/index.html"
      ><span>종료하고 진짜</span> <span>훈련하러 가기 GO!GO!</span></a
    >
  </section>
</div>

.cheerup-modal__wrapper가 화면 전체를 뒤덮게 해서 거기에 블러처리를 하려고 했습니다.

"종료하고 진짜 훈련하러 가기" <button> 요소가 위니브 홈페이지로 가는 링크라고 생각하고 뒷 배경을 누르면 modal 창이 닫히게 하려고 했는데, 모바일에서는 뒷배경이 거의 남지 않는 문제가 있어 modal창 자체를 누르면 닫히게 했습니다.

실무였다면 디자이너와 협의해 닫기 버튼을 추가하는 등의 시도를 해도 좋을 것 같습니다.

CSS
.cheerup-modal__wrapper {
  /*
    페이지가 처음 로드될 때 modal이 보이지 않도록
    display:none;을 부여합니다.
  */
  display: none;

  /*
    wrapper는 모든 다른 요소 위에 위치해야 하므로
    position property를 fixed로 지정합니다.
  */
  position: fixed;
  top: 0;

  /* Viewport 꽉 차게 만듭니다. */
  width: 100vw;
  height: 100vh;

  backdrop-filter: blur(10px);
  animation: modalClose 0.4s;
}

.cheerup-modal {
  position: fixed;
  top: 50%;
  left: 50%;
  width: 800px;
  min-height: 800px;
  /*
    min-height 를 줌으로써 modal 창 내의
    컨텐츠가 늘어도 대응이 가능하도록 합니다.
  */
  padding-top: 76px;
  padding-bottom: 68px;
  border-radius: 30px;
  box-sizing: border-box;
  text-align: center;
  color: var(--main-color);
  background-color: var(--second-main-color);
  box-shadow: 0px 0px 20px rgba(0, 0, 0, 0.5);
  transform: translateX(-50%) translateY(-50%);
}

.cheerup-modal .btn-point-colored {
  margin-top: 74px;
}

.cheerup-modal .cheerup-modal__txt {
  margin-bottom: 29px;
  font-size: 2.25rem;
  font-weight: 700;
  font-family: "OTEnjoystoriesBA";
}

.cheerup-modal .cheerup-modal__txt strong {
  font-size: 6rem;
  line-height: 6.625rem;
}

.cheerup-modal .cheerup-modal__txt span:not(.hide-mobile) {
  /*
    줄바꿈이 필요한 "당신의 꿈을 응원합니다" span 요소에 display:block;을 부여합니다.
    모바일에서는 보이지 않아야 하는 하트 부분을 묶은 span 요소는 무시할 수 있도록
    not pesudo class를 이용합니다.
  */
  display: block;
  margin-top: 4px;
  line-height: 2.5rem;
}

/*
  modal 작동 관련 애니메이션
*/

@keyframes modalOpen {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}

@keyframes modalClose {
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

/*
  modal을 감싸는 wrapper div 요소,
  클릭되면 modal을 닫거나
  backdrop-filter: blur()를 줘서 뒷배경 초점을
  흐리게 만드는 효과를 부여합니다.
*/

.cheerup-modal__wrapper.activated {
  animation: modalOpen 0.4s;
}
.cheerup-modal__wrapper {
  /*
    페이지가 처음 로드될 때 modal이 보이지 않도록
    display:none;을 부여합니다.
  */
  display: none;

  /*
    wrapper는 모든 다른 요소 위에 위치해야 하므로
    position property를 fixed로 지정합니다.
  */
  position: fixed;
  top: 0;

  /* Viewport 꽉 차게 만듭니다. */
  width: 100vw;
  height: 100vh;

  backdrop-filter: blur(10px);
  animation: modalClose 0.4s;
}

.cheerup-modal {
  position: fixed;
  top: 50%;
  left: 50%;
  width: 800px;
  min-height: 800px;
  /*
    min-height 를 줌으로써 modal 창 내의
    컨텐츠가 늘어도 대응이 가능하도록 합니다.
  */
  padding-top: 76px;
  padding-bottom: 68px;
  border-radius: 30px;
  box-sizing: border-box;
  text-align: center;
  color: var(--main-color);
  background-color: var(--second-main-color);
  box-shadow: 0px 0px 20px rgba(0, 0, 0, 0.5);
  transform: translateX(-50%) translateY(-50%);
}

.cheerup-modal .btn-point-colored {
  margin-top: 74px;
}

.cheerup-modal .cheerup-modal__txt {
  margin-bottom: 29px;
  font-size: 2.25rem;
  font-weight: 700;
  font-family: "OTEnjoystoriesBA";
}

.cheerup-modal .cheerup-modal__txt strong {
  font-size: 6rem;
  line-height: 6.625rem;
}

.cheerup-modal .cheerup-modal__txt span:not(.hide-mobile) {
  /*
    줄바꿈이 필요한 "당신의 꿈을 응원합니다" span 요소에 display:block;을 부여합니다.
    모바일에서는 보이지 않아야 하는 하트 부분을 묶은 span 요소는 무시할 수 있도록
    not pesudo class를 이용합니다.
  */
  display: block;
  margin-top: 4px;
  line-height: 2.5rem;
}

/*
  modal 작동 관련 애니메이션
*/

@keyframes modalOpen {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}

@keyframes modalClose {
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

/*
  modal을 감싸는 wrapper div 요소,
  클릭되면 modal을 닫거나
  backdrop-filter: blur()를 줘서 뒷배경 초점을
  흐리게 만드는 효과를 부여합니다.
*/

.cheerup-modal__wrapper.activated {
  animation: modalOpen 0.4s;
}

.cheerup-modal__wrapper가 화면 전체를 감싸도록 했으므로 .cheerup-modal에서는 position: fixed;top, left50%로 줘서 .cheerup-modal의 좌상단을 Viewport의 가운데로 맞춘 후, transform proprerty를 이용해 창 자체가 가운데로 올 수 있도록 합니다.

JS에서 .activated 클래스를 부여하거나 삭제해 애니메이션을 구현하고자 했습니다.

멘토님 Feedback

  • 디자이너와 협의해 닫기 버튼을 추가하는 등의 시도를 해도 좋을 것 같습니다. -> 좋은 방법입니다!
    - 실제 1만시간의 법칙 사이트에서는 "종료하고 진짜 훈련하러 가기 GO!GO!"가 닫기 버튼인데 다른 링크를 연결하셨더라구요. 그래서 현재 닫기 버튼이 없는 상황인데 모달에 닫기 버튼을 추가하는 것 좋은 방법인 것 같습니다. :)

멘토님 HTML, CSS 총평

A.


전체적으로 마크업 잘 작성하셨네요! ㅎㅎ 용도, 목적에 맞는 태그를 적절히 잘 사용하셨습니다 :)



css도 정말 잘하셨어요! ㅎㅎ css에서는 적용된 속성 중 중복된 건 없는지 또는 동일한 동작을 다른 방법으로 할 수는 없는지 고민해보면 더 공부가 될 것 같습니다.


(예: text-align, transform 등의 속성을 사용해서 정렬하는 걸 flex를 사용해서 해보기)

JavaScript

로직이 필요한 부분의 구현을 위해 간단한 스크립트를 작성했습니다. 참고로 JS상에서 접근이 필요한 요소들에는 ~-js 클래스를 추가해 스타일을 위한 클래스명이 바뀌더라도 로직은 작동할 수 있도록 했습니다.

요소를 가져오기

/**
 * 클래스 이름들로 요소 Ref 를 반환하는 함수
 * @param {string} classNames
 * @returns {HTMLCollectionOf<Element>} 요소 Collection
 */
const getByClass = (classNames) => document.getElementsByClassName(classNames)

const $ruleForm = getByClass("rule-form-js")[0]
const $inputTarget = getByClass("rule-form__input-target-js")[0]
const $inputHours = getByClass("rule-form__input-hours-js")[0]
const $resultTarget = getByClass("result__target-js")[0]
const $resultDays = getByClass("result__days-js")[0]
const $shareBtn = getByClass("share-link-js")[0]
const $modalOpenerBtn = getByClass("cheerup-modal__opener-js")[0]
const $modalWrapper = getByClass("cheerup-modal__wrapper-js")[0]
/**
 * 클래스 이름들로 요소 Ref 를 반환하는 함수
 * @param {string} classNames
 * @returns {HTMLCollectionOf<Element>} 요소 Collection
 */
const getByClass = (classNames) => document.getElementsByClassName(classNames)

const $ruleForm = getByClass("rule-form-js")[0]
const $inputTarget = getByClass("rule-form__input-target-js")[0]
const $inputHours = getByClass("rule-form__input-hours-js")[0]
const $resultTarget = getByClass("result__target-js")[0]
const $resultDays = getByClass("result__days-js")[0]
const $shareBtn = getByClass("share-link-js")[0]
const $modalOpenerBtn = getByClass("cheerup-modal__opener-js")[0]
const $modalWrapper = getByClass("cheerup-modal__wrapper-js")[0]

지금 보니까 너무 부끄러운 코드인데,,, 우선 멘토님 피드백을 보겠습니다.

멘토님 Feedback

A.


querySelector를 사용하면 배열 접근을 추가로 하지 않아도 되는데 getElementsByClassName를 사용하는 이유가 궁금합니다.(이렇게 하면 안된다는 얘기는 아닙니다.)

실수노트에 추가한 내용... 처음에는 각 요소에 id를 부여해서 접근하고자 했기에 코드를 getElementById 메소드를 이용해 작성하고 있었는데 그걸 클래스로 접근해야겠다 생각하고 바꾸다가 비슷하게 생긴 getElementsByClassName에 꽂혀버렸기에 벌어진 일입니다.

Resolve

아래 코드로 얼른 수정했습니다.

/**
 * Selector로 요소 Ref 를 반환하는 함수
 * @param {string} selector
 * @returns {Element}
 */
const getBySelector = (selector) => document.querySelector(selector)

const $ruleForm = getBySelector(".rule-form-js")
const $inputTarget = getBySelector(".rule-form__input-target-js")
const $inputHours = getBySelector(".rule-form__input-hours-js")
const $resultTarget = getBySelector(".result__target-js")
const $resultDays = getBySelector(".result__days-js")
const $shareBtn = getBySelector(".share-link-js")
const $modalOpenerBtn = getBySelector(".cheerup-modal__opener-js")
const $modalWrapper = getBySelector(".cheerup-modal__wrapper-js")
/**
 * Selector로 요소 Ref 를 반환하는 함수
 * @param {string} selector
 * @returns {Element}
 */
const getBySelector = (selector) => document.querySelector(selector)

const $ruleForm = getBySelector(".rule-form-js")
const $inputTarget = getBySelector(".rule-form__input-target-js")
const $inputHours = getBySelector(".rule-form__input-hours-js")
const $resultTarget = getBySelector(".result__target-js")
const $resultDays = getBySelector(".result__days-js")
const $shareBtn = getBySelector(".share-link-js")
const $modalOpenerBtn = getBySelector(".cheerup-modal__opener-js")
const $modalWrapper = getBySelector(".cheerup-modal__wrapper-js")

EventListener

$ruleForm.addEventListener("submit", getAndShowResults)
$shareBtn.addEventListener("click", copyShareLink)
$modalOpenerBtn.addEventListener("click", openModal)
$modalWrapper.addEventListener("click", closeModal)

$modalWrapper.addEventListener("wheel", preventScroll, { passive: false })
$modalWrapper.addEventListener("touchmove", preventScroll, {
  passive: false,
})

/**
 * modal 배경 스크롤을 방지하기 위한 함수
 * @param {Event} event
 */
function preventScroll(event) {
  event.preventDefault()
  event.stopPropagation()
  return false
}
$ruleForm.addEventListener("submit", getAndShowResults)
$shareBtn.addEventListener("click", copyShareLink)
$modalOpenerBtn.addEventListener("click", openModal)
$modalWrapper.addEventListener("click", closeModal)

$modalWrapper.addEventListener("wheel", preventScroll, { passive: false })
$modalWrapper.addEventListener("touchmove", preventScroll, {
  passive: false,
})

/**
 * modal 배경 스크롤을 방지하기 위한 함수
 * @param {Event} event
 */
function preventScroll(event) {
  event.preventDefault()
  event.stopPropagation()
  return false
}

각종 이벤트 리스너들을 추가하는 부분입니다. $modalWrapper 요소에 PC와 모바일에서의 스크롤 이벤트를 방지하는 코드를 추가했습니다. modal 창을 뚫고 스크롤이 되면 안되니까요!

이벤트 버블링은 막았지만 캡처링을 건들지는 못해서 modal이 길어질 경우 modal 자체의 스크롤이 안됩니다. 추후 더 좋은 방법이 있을지 고민 해봐야겠습니다.

입력 <section>, 결과 <section>

/**
 * 입력값이 들어오는 `<form>` 요소의
 * `submit` 이벤트를 헨들링하는 함수
 * @param {SubmitEvent} event
 */
function getAndShowResults(event) {
  const targetVal = $inputTarget.value
  const hoursVal = parseFloat($inputHours.value)
  // 각 입력값을 불러옵니다.
  // 시간은 float으로 형변환합니다.

  // form에서 유효성검사를 해서 오므로
  // 각 값에 대해 JS에서 추가로 검사할 필요는 없습니다.

  // 결과가 들어갈 곳을 채웁니다.
  $resultTarget.textContent = targetVal
  $resultDays.textContent = calDaysForTarget(hoursVal)

  // 결과로 자동 스크롤
  $resultTarget.scrollIntoView({ behavior: "smooth" })

  // default action의 작동을 막습니다.
  event.preventDefault()
}

/**
 * 1만 시간까지 몇 일 걸릴지 계산하는 함수
 * @param {number} hoursPerDay - 하루에 투자할 시간
 * @return {string} 1만 시간까지 걸리는 일 수
 */
function calDaysForTarget(hoursPerDay) {
  return Math.round(10000 / hoursPerDay).toString()
}
/**
 * 입력값이 들어오는 `<form>` 요소의
 * `submit` 이벤트를 헨들링하는 함수
 * @param {SubmitEvent} event
 */
function getAndShowResults(event) {
  const targetVal = $inputTarget.value
  const hoursVal = parseFloat($inputHours.value)
  // 각 입력값을 불러옵니다.
  // 시간은 float으로 형변환합니다.

  // form에서 유효성검사를 해서 오므로
  // 각 값에 대해 JS에서 추가로 검사할 필요는 없습니다.

  // 결과가 들어갈 곳을 채웁니다.
  $resultTarget.textContent = targetVal
  $resultDays.textContent = calDaysForTarget(hoursVal)

  // 결과로 자동 스크롤
  $resultTarget.scrollIntoView({ behavior: "smooth" })

  // default action의 작동을 막습니다.
  event.preventDefault()
}

/**
 * 1만 시간까지 몇 일 걸릴지 계산하는 함수
 * @param {number} hoursPerDay - 하루에 투자할 시간
 * @return {string} 1만 시간까지 걸리는 일 수
 */
function calDaysForTarget(hoursPerDay) {
  return Math.round(10000 / hoursPerDay).toString()
}
멘토님 Feedback

A.


  • 현재 페이지 접속시 처음부터 결과가 나오게 되어 있더라구요! 모달에서 display 속성을 사용했던 것처럼 이 부분도 처음에는 안 보이도록 작업해주시면 좋을 것 같습니다.

  • 보통 form에서 제공하는 기본적인 유효성 검사를 사용하더라도 js에서 추가적으로 유효성 검사가 필요합니다.

+) 추가로 제출 후 input이 초기화되면 좋을 것 같아요! ㅎㅎ

페이지 접속시에는 가렸다가 submit 이벤트가 유효성 검사를 통과했을 때 보이게 하는건 유진 멘토님께서 말씀하신 것처럼 추가로 구현하겠습니다.

유효성 검사는 HTML에서 하니 같은 로직을 JS에서도 구현하는건 너무 중복되는게 아닌가 고민됐습니다. 하지만 유진 멘토님 말씀대로 안정적인 서비스를 위해 '유효성 검사' 만큼은 여러번 해도 지나치지 않다고 생각을 바꾸려고 합니다.

Resolve

tools.css

.transition {
  -webkit-transition: all 0.2s ease;
  -o-transition: all 0.2s ease;
  transition: all 0.2s ease;
}

.deactivated {
  position: fixed;
  left: 0;
  top: 100%;
  opacity: 0;
}

.activated {
  opacity: 1;
}

.cheerup-modal__wrapper.activated {
  /*
    wrapper는 모든 다른 요소 위에 위치해야 하므로
    position property를 fixed로 지정합니다.
  */
  position: fixed;
  top: 0;
}
.transition {
  -webkit-transition: all 0.2s ease;
  -o-transition: all 0.2s ease;
  transition: all 0.2s ease;
}

.deactivated {
  position: fixed;
  left: 0;
  top: 100%;
  opacity: 0;
}

.activated {
  opacity: 1;
}

.cheerup-modal__wrapper.activated {
  /*
    wrapper는 모든 다른 요소 위에 위치해야 하므로
    position property를 fixed로 지정합니다.
  */
  position: fixed;
  top: 0;
}

아래에 나올 modal과 지금 수정하고 있는 결과 <section> 요소처럼 가려져 있다가 나타나는 요소들에 .transition 클래스를 부여한 후 .activated, .deactivated 클래스를 작동 상황에 따라 부여하겠습니다.

/**
 * `.activated` / `.deactivated` class 토글
 * @param {Element} element
 */
function toggleActivation(element) {
  const state = element.classList.contains("activated")
  const classToRemove = state ? "activated" : "deactivated"
  const classToAdd = state ? "deactivated" : "activated"

  element.classList.remove(classToRemove)
  element.classList.add(classToAdd)
}

/**
 * 입력값이 들어오는 `<form>` 요소의
 * `submit` 이벤트를 헨들링하는 함수
 * @param {SubmitEvent} event
 */
function getAndShowResults(event) {
  const targetVal = $inputTarget.value
  const hoursVal = parseFloat($inputHours.value)
  // 각 입력값을 불러옵니다.
  // 시간은 float으로 형변환합니다.

  /* 유효성 검사 */
  if (targetVal === "" || hoursVal === NaN) {
    return
  }

  // 결과가 들어갈 곳을 채웁니다.
  $resultTarget.textContent = targetVal
  $resultDays.textContent = calDaysForTarget(hoursVal)

  // 가려져 있던 결과 section을 보여줍니다.
  if ($resultSection.classList.contains("deactivated")) toggleActivation($resultSection)

  // 결과로 자동 스크롤
  $resultTarget.scrollIntoView({ behavior: "smooth" })

  // input의 입력값을 초기화합니다.
  $inputTarget.value = ""
  $inputHours.value = ""

  // default action의 작동을 막습니다.
  event.preventDefault()
}
/**
 * `.activated` / `.deactivated` class 토글
 * @param {Element} element
 */
function toggleActivation(element) {
  const state = element.classList.contains("activated")
  const classToRemove = state ? "activated" : "deactivated"
  const classToAdd = state ? "deactivated" : "activated"

  element.classList.remove(classToRemove)
  element.classList.add(classToAdd)
}

/**
 * 입력값이 들어오는 `<form>` 요소의
 * `submit` 이벤트를 헨들링하는 함수
 * @param {SubmitEvent} event
 */
function getAndShowResults(event) {
  const targetVal = $inputTarget.value
  const hoursVal = parseFloat($inputHours.value)
  // 각 입력값을 불러옵니다.
  // 시간은 float으로 형변환합니다.

  /* 유효성 검사 */
  if (targetVal === "" || hoursVal === NaN) {
    return
  }

  // 결과가 들어갈 곳을 채웁니다.
  $resultTarget.textContent = targetVal
  $resultDays.textContent = calDaysForTarget(hoursVal)

  // 가려져 있던 결과 section을 보여줍니다.
  if ($resultSection.classList.contains("deactivated")) toggleActivation($resultSection)

  // 결과로 자동 스크롤
  $resultTarget.scrollIntoView({ behavior: "smooth" })

  // input의 입력값을 초기화합니다.
  $inputTarget.value = ""
  $inputHours.value = ""

  // default action의 작동을 막습니다.
  event.preventDefault()
}
  • 결과를 보여주는 <section>.deactivated 클래스가 부여돼 있다면 .activated로 toggle합니다.
  • submit 이벤트가 일어나면 <input>을 비워주는 코드를 추가했습니다.
  • openModal(), closeModal() 함수는 필요 없어졌으므로 삭제했습니다.
/**
 * 모달 창을 열거나 닫는 함수
 * @returns {none}
 */
const toggleModalActivation = () => toggleActivation($modalWrapper)

$modalOpenerBtn.addEventListener("click", toggleModalActivation)
$modalWrapper.addEventListener("click", toggleModalActivation)
/**
 * 모달 창을 열거나 닫는 함수
 * @returns {none}
 */
const toggleModalActivation = () => toggleActivation($modalWrapper)

$modalOpenerBtn.addEventListener("click", toggleModalActivation)
$modalWrapper.addEventListener("click", toggleModalActivation)

앞서 수정한 코드에 이어서 수정한 내용입니다.

간단하게 "훈련하러 가기 GO!GO!" 버튼 혹은 modal을 감싸고 있는 wrapper <div>를 누르면 toggleActivation($modalWrapper)를 호출하도록 해 구현했습니다.

결과 <section>에서 '공유하기' 버튼

/**
 * 공유할 링크를
 * 사용자의 클립보드에 복사하는 함수
 *
 * 복사에는 `Clipboard API`를 사용했습니다.
 *
 * [참고한 MDN 문서](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API)
 * @param {MouseEvent} event
 */
async function copyShareLink(event) {
  // event를 일으킨 요소에서 data attribute를 통해 복사할 링크를 가져옵니다.
  const link = event.target.dataset.copiedlink

  // writeText 메소드는 비동기적으로 작동합니다.
  await navigator.clipboard.writeText(link)

  window.alert("🙌 링크가 복사됐습니다 🙌\n친구에게 공유해보세요 😁👍")
}
/**
 * 공유할 링크를
 * 사용자의 클립보드에 복사하는 함수
 *
 * 복사에는 `Clipboard API`를 사용했습니다.
 *
 * [참고한 MDN 문서](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API)
 * @param {MouseEvent} event
 */
async function copyShareLink(event) {
  // event를 일으킨 요소에서 data attribute를 통해 복사할 링크를 가져옵니다.
  const link = event.target.dataset.copiedlink

  // writeText 메소드는 비동기적으로 작동합니다.
  await navigator.clipboard.writeText(link)

  window.alert("🙌 링크가 복사됐습니다 🙌\n친구에게 공유해보세요 😁👍")
}

Clipboard API는 이번에 처음 알게 됐는데 재밌었습니다ㅎㅎ

멘토님 Feedback

유진님 A.


  • Clipboard API 사용하신 것 잘하셨습니다! 예외처리도 추가하면 좋을 것 같아요!

  • 현재 url을 가져오는 방법이 있는데 dataset을 가져오는 방식으로 구현하신 이유가 궁금합니다. 물론 이 방법도 괜찮습니다 :)


시우 A. 추후에 공유하기 버튼이 단순 URL을 복붙하기 보다 유저가 결과를 공유할수도 있게 하려면 어떻게 해야할까를 고민하다가 그렇게 짜게 됐습니다,,ㅎㅎㅎ



유진님 A. 아하! 그러셨군요! 그럼 sns 공유, 이미지 공유 등 여러가지 공유하는 방법에 대해서 알아보시는 것도 추천드려요! ㅎㅎ

dataset을 가져오는 방식이란 일종의 '커스텀 attribute'로, data-*꼴로 넣을 수 있습니다.

예를 들어 저는 1만 시간의 법칙 페이지에서

<button
  class="btn-second-main-colored share-link share-link-js"
  data-copiedlink="https://custardcream98.github.io/the-10000-hours-rule/"
  type="button"
>
  공유하기
</button>
<button
  class="btn-second-main-colored share-link share-link-js"
  data-copiedlink="https://custardcream98.github.io/the-10000-hours-rule/"
  type="button"
>
  공유하기
</button>

이런 코드를 사용했습니다.

유진님께서 피드백 주신대로 단순 링크 복사보다는 더 멋진 무언가를 구현하면 좋을 것 같아요. 이건 추후에 더 붙여보는걸로 하겠습니다!

멘토님 JS 총평

함수명을 봤을 때 어떤 역할을 하는지 명시적으로 알 수 있어서 좋습니다! 그리고 param을 사용해서 주석을 잘 작성해줘서 흐름 파악하는데도 좋았습니다.:)

멘토님 보기 좋으시게 JSDoc을 약간 작성했는데 칭찬받아서 기분이 좋네요 😆

마무으리 멘트

이 글 처음 쓰기 시작했을 때는 이렇게 시간이 오래 걸릴줄 몰랐는데 생각보다 엄청 오래 걸렸네요 😅

그래도 처음으로 디자이너님의 시안을 바탕으로 웹페이지 개발을 해봤는데 너무너무 재밌었습니다. 멘토님의 코드리뷰를 받고 고쳐나가는 과정도 정말 좋았구요. 이렇게 열심히 정리하고 보니 뿌듯하기도 하고, 코딩 과정에서 어떤 부분이 미숙했는지도 다시 한번 곱씹어 볼 수 있었습니다!