درس تطبيقي حول الترميز: إنشاء مكوِّن للقصص

يشرح لك هذا الدرس التطبيقي حول الترميز كيفية إنشاء تجربة مشابهة لـ "قصص Instagram" على الويب. سننشئ المكوّن أثناء التقدّم، بدءًا من HTML ثم CSS، ثم JavaScript.

يمكنك الاطّلاع على مقالة المدوّنة إنشاء مكوّن "قصص" للتعرّف على التحسينات التدريجية التي تم إجراؤها أثناء إنشاء هذا المكوّن.

ضبط إعدادات الجهاز

  1. انقر على Remix to Edit (إنشاء ريمكس لتعديل المحتوى) ليصبح المشروع قابلاً للتعديل.
  2. فتح "app/index.html"

HTML

أسعى دائمًا إلى استخدام ترميز HTML الدلالي. بما أنّه يمكن لكل صديق إنشاء أي عدد من القصص، قرّرتُ استخدام عنصر <section> لكل صديق وعنصر <article> لكل قصة. لنبدأ من البداية. أولاً، نحتاج إلى حاوية لمكوّن قصصنا.

أضِف عنصر <div> إلى <body>:

<div class="stories">

</div>

أضِف بعض عناصر <section> لتمثيل الأصدقاء:

<div class="stories">
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
</div>

أضِف بعض عناصر <article> لتمثيل القصص:

<div class="stories">
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/480/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/840);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/482/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/843);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/844);"></article>
  </section>
</div>
  • نحن نستخدم خدمة صور (picsum.com) للمساعدة في إنشاء نماذج أولية للقصص.
  • تشكّل سمة style في كل <article> جزءًا من أسلوب loading البديل، ويمكنك الاطّلاع على مزيد من المعلومات عنه في القسم التالي.

CSS

المحتوى جاهز للتنسيق. لنحوّل هذه العظام إلى محتوى يريده المستخدمون ويتفاعلون معه. سنركّز اليوم على الأجهزة الجوّالة.

.stories

بالنسبة إلى حاوية <div class="stories">، نريد حاوية لفّ أفقي. يمكننا تحقيق ذلك من خلال:

  • جعل الحاوية شبكة
  • ضبط كل طفل لملء مسار الصف
  • جعل عرض كل عنصر فرعي هو عرض مساحة العرض على الشاشة للأجهزة الجوّالة

سيواصل "الشبكة" وضع أعمدة جديدة بعرض 100vw على يسار عمود السابق، إلى أن يتم وضع كل عناصر HTML في علاماتك.

فتح Chrome و&quot;أدوات مطوّري البرامج&quot; مع عرض شبكة رسومية تعرض التنسيق بالعرض الكامل
تعرض "أدوات مطوّري البرامج في Chrome" تدفّقًا زائدًا لعمود الشبكة، ما يؤدي إلى إنشاء شريط تمرير أفقي.

أضِف ملف CSS التالي إلى أسفل app/css/index.css:

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
}

والآن بعد أن أصبح لدينا محتوى يتجاوز إطار العرض، حان وقت إخبار الحاوية بكيفية التعامل معه. أضِف أسطر الرمز البرمجي المميّزة إلى قواعد .stories:

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  overscroll-behavior: contain;
  touch-action: pan-x;
}

نريد التمرير الأفقي، لذا سنضبط overflow-x على auto. عندما ينتقل المستخدم للأعلى أو للأسفل، نريد أن يستقر المكوّن برفق على القصة التالية، لذلك سنستخدم scroll-snap-type: x mandatory. يمكنك الاطّلاع على مزيد من المعلومات حول ملف CSS في قسمَي نقاط التصاق التمرير في CSS وoverscroll-behavior في مشاركة المدوّنة.

يجب أن توافق كلّ من الحاوية الرئيسية والعناصر الثانوية على ميزة "الانتقال السريع"، لذلك دعونا نتناول هذه النقطة الآن. أضِف الرمز التالي إلى أسفل app/css/index.css:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

لا يعمل تطبيقك بعد، ولكن يوضّح الفيديو أدناه ما يحدث عند تفعيل ميزة scroll-snap-type وإيقافها. عند تفعيل هذه الميزة، يتم الانتقال إلى القصة التالية عند التمرير بشكل أفقي. وعند إيقاف هذه الميزة، يستخدم المتصفِّح سلوك التمرير التلقائي.

سيؤدي ذلك إلى تصفّح أصدقائك، ولكن لا تزال لدينا مشكلة في القصص يجب حلّها.

.user

لننشئ تنسيقًا في القسم .user يُعيد ترتيب عناصر القصص الفرعية. سنستخدم خدعة رائعة لحلّ هذه المشكلة. نحن بصدد إنشاء شبكة 1×1 يكون فيها الصف والعمود يحملان العنوان الرمزي نفسه للشبكة [story]، وسيحاول كل عنصر في شبكة القصص الحصول على هذه المساحة، مما يؤدي إلى تجميعها.

أضِف الرمز المميّز إلى قواعد .user:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
  display: grid;
  grid: [story] 1fr / [story] 1fr;
}

أضِف مجموعة القواعد التالية إلى أسفل app/css/index.css:

.story {
  grid-area: story;
}

الآن، بدون استخدام موضع مطلق أو عناصر تطفو أو توجيهات تخطيط أخرى تؤدي إلى إخراج عنصر من التدفق، لا يزال لدينا تدفق. بالإضافة إلى ذلك، لا يتطلّب الأمر استخدام أي رموز، انظر إلى ذلك. يمكنك الاطّلاع على المزيد من التفاصيل في الفيديو ومشاركة المدونة.

.story

الآن ما علينا سوى تصميم عنصر القصة نفسه.

ذكرنا سابقًا أنّ سمة style في كل عنصر <article> هي جزء من أسلوب loading placeholder (تحميل العنصر النائب):

<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>

سنستخدم سمة background-image في CSS، والتي تسمح لنا بتحديد أكثر من صورة خلفية واحدة. يمكننا ترتيبها بحيث تظهر صورة العميل في أعلى الصفحة وستظهر تلقائيًا عند انتهاء التحميل. لتفعيل هذه الميزة، سنضع عنوان URL للصورة في سمة مخصّصة (--bg)، وسنستخدمه ضمن ملف CSS لدمج العنصر النائب لتحميل المحتوى.

أولاً، لنعدِّل مجموعة قواعد .story لاستبدال التدرّج بخلفية مصوّرة بعد انتهاء التحميل. أضِف الرمز المميّز إلى قواعد .story:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}

يضمن ضبط background-size على cover عدم توفّر مساحة فارغة في مجال العرض لأنّ صورتنا ستملؤه. من خلال تحديد صورتَي خلفية، يمكننا استخدام خدعة رائعة على الويب باستخدام CSS تُعرف باسم loading tombstone:

  • صورة الخلفية 1 (var(--bg)) هي عنوان URL الذي أدخلناه في ملف HTML.
  • صورة الخلفية 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) هي صورة متدرجة لعرضها أثناء تحميل عنوان URL

ستستبدل CSS التدرّج اللوني بالصورة تلقائيًا بعد اكتمال تنزيل الصورة.

بعد ذلك، سنضيف بعض تنسيقات CSS لإزالة بعض السلوكيات، ما يسمح للمتصفّح بالانتقال بشكل أسرع. أضِف الرمز المميّز إلى قواعد .story:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;
}
  • يمنع user-select: none المستخدمين من اختيار نص عن طريق الخطأ.
  • يوجّه touch-action: manipulation المتصفّح إلى أنّه يجب التعامل مع هذه التفاعلات كأحداث لمس، ما يُخفّف من عبء المتصفّح في محاولة تحديد ما إذا كنت تنقر على عنوان URL أم لا.

أخيرًا، لنضيف القليل من CSS لإضافة تأثيرات متحركة إلى عملية الانتقال بين القصص. أضِف الرمز المميّز إلى قواعد .story:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;

  transition: opacity .3s cubic-bezier(0.4, 0.0, 1, 1);

  &.seen {
    opacity: 0;
    pointer-events: none;
  }
}

ستتم إضافة فئة .seen إلى قصة تحتاج إلى مخرج. حصلتُ على دالة التخفيف المخصّصة (cubic-bezier(0.4, 0.0, 1,1)) من دليل التخفيف في Material Design (انتقِل إلى قسم التخفيف المتسارع).

إذا كانت لديك عين ثاقبة، ربما لاحظت pointer-events: none بيان سياسة الخصوصية وربما تساءلت عن سبب ظهوره. أعتقد أنّ هذا هو الجانب السلبي الوحيد للحلّ حتى الآن. نحتاج إلى ذلك لأنّ عنصر .seen.story سيكون في الأعلى وسيتلقّى النقرات، حتى لو كان غير مرئي. من خلال ضبط القيمة pointer-events على none، يمكننا تحويل قصة الزجاج إلى نافذة، ولن نأخذ بعد ذلك أي تفاعلات من المستخدمين. ليس هذا خيارًا سيئًا، ولا يصعب إدارته في CSS نحن لا نتعامل مع z-index. لا أزال أرى أنّ هذا الإجراء مناسب.

JavaScript

إنّ تفاعلات مكوّن "قصص" بسيطة جدًا للمستخدم: انقر على اليمين للانتقال إلى الأمام، وانقر على اليسار للرجوع. إنّ الإجراءات البسيطة للمستخدمين تتطلّب عادةً جهدًا كبيرًا من المطوّرين. سنتولى الكثير من هذه المهام.

ضبط إعدادات الجهاز

للبدء، لنحسب أكبر قدر ممكن من المعلومات ونخزّنها. أضِف الرمز التالي إلى app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

يحصل السطر الأول من JavaScript على مرجع إلى جذر عنصر HTML الأساسي ويخزّنه. يحسب السطر التالي موضع منتصف العنصر، حتى تتمكّن من تحديد ما إذا كانت النقرة ستؤدي إلى التقديم أو الإيقاف.

الحالة

بعد ذلك، ننشئ كائنًا صغيرًا يتضمّن بعض الحالات ذات الصلة بمنطقنا. في هذا السياق، لا يهمّنا سوى القصة الحالية. في ترميز HTML، يمكننا الوصول إليه من خلال الحصول على الصديق الأول وأحدث قصته. أضِف الرمز المميّز إلى app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

أدوات معالجة الأحداث

لدينا الآن منطق كافٍ لبدء الاستماع إلى أحداث المستخدِمين وتوجيهها.

فأر

لنبدأ بالاستماع إلى حدث 'click' في حاوية القصص. أضِف الرمز المميّز إلى app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

إذا حدثت نقرة ولم تكن على عنصر <article>، نتوقف عن المعالجة ولا نتّخذ أي إجراء. إذا كانت مقالة، نحصل على الموضع الأفقي للماوس أو الإصبع باستخدام clientX. لم نفّذ navigateStories بعد، ولكن الوسيطة التي يتم استخدامها تحدد الاتجاه الذي يجب اتّخاذه. إذا كان موضع المستخدِم هذا أكبر من المتوسط، نعرف أنّنا بحاجة إلى الانتقال إلى next، وإلّا، prev (السابق).

لوحة المفاتيح

الآن، لنستمع إلى الضغطات على لوحة المفاتيح. في حال الضغط على السهم المتّجه للأسفل، سيتم التنقّل إلى next. إذا كان السهم المتّجه للأعلى، ننتقل إلى prev.

أضِف الرمز المميّز إلى app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

document.addEventListener('keydown', ({key}) => {
  if (key !== 'ArrowDown' || key !== 'ArrowUp')
    navigateStories(
      key === 'ArrowDown'
        ? 'next'
        : 'prev')
})

التنقّل في القصص

لقد حان الوقت للتعرّف على منطق النشاط التجاري الفريد للقصص وتجربة المستخدم التي أصبحت مشهورة بها. يبدو هذا الإجراء معقّدًا، ولكن أعتقد أنّه إذا قرأته سطرًا سطرًا، ستتبيّن لك سهولة فهمه.

في البداية، نضع بعض المحدّدات التي تساعدنا في تحديد ما إذا كان علينا الانتقال إلى ملف شخصي لصديق أو عرض قصة أو إخفاؤها. بما أنّنا نعمل على تنسيق HTML، سنبحث فيه عن تواجد الأصدقاء (المستخدمين) أو القصص (القصة).

ستساعدنا هذه المتغيّرات في الإجابة عن أسئلة مثل "في القصة x، هل يعني الخيار "التالي" الانتقال إلى قصة أخرى من هذا الصديق نفسه أو إلى صديق مختلف؟" وقد نجحت في ذلك باستخدام البنية المتفرعة التي أنشأناها للوصول إلى الوالدَين وأطفالهما.

أضِف الرمز التالي إلى أسفل app/js/index.js:

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling
}

في ما يلي هدف منطق النشاط التجاري، بصياغة قريبة من اللغة الطبيعية قدر الإمكان:

  • تحديد كيفية التعامل مع النقرة
    • إذا كانت هناك قصة تالية/سابقة: عرض تلك القصة
    • إذا كانت هذه هي القصة الأخيرة أو الأولى للصديق: عرض صديق جديد
    • إذا لم تكن هناك قصة تستحق المتابعة، لا داعي لاتّخاذ أي إجراء.
  • إخفاء القصة الحالية الجديدة في state

أضِف الرمز المميّز إلى دالة navigateStories:

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling

  if (direction === 'next') {
    if (lastItemInUserStory === story && !hasNextUserStory)
      return
    else if (lastItemInUserStory === story && hasNextUserStory) {
      state.current_story = story.parentElement.nextElementSibling.lastElementChild
      story.parentElement.nextElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.classList.add('seen')
      state.current_story = story.previousElementSibling
    }
  }
  else if(direction === 'prev') {
    if (firstItemInUserStory === story && !hasPrevUserStory)
      return
    else if (firstItemInUserStory === story && hasPrevUserStory) {
      state.current_story = story.parentElement.previousElementSibling.firstElementChild
      story.parentElement.previousElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.nextElementSibling.classList.remove('seen')
      state.current_story = story.nextElementSibling
    }
  }
}

جرّبه الآن

  • لمعاينة الموقع الإلكتروني، اضغط على عرض التطبيق. ثم اضغط على ملء الشاشة ملء الشاشة.

الخاتمة

هذه هي المتطلّبات التي كانت لديّ بشأن المكوّن. لا تتردد في الاستفادة من هذه الميزة وتطويرها باستخدام البيانات، واستخدامها بشكل عام بما يناسبك.