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

يشرح لك هذا الدرس التطبيقي حول الترميز كيفية إنشاء تجربة مشابهة لـ "قصص 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 Scroll Snap Points وسلوك الانتقال الزائدة في مشاركة مدونتي.

يتطلب الأمر توافق كل من الحاوية الأصلية والعناصر الفرعية على توافق محاذاة التمرير، لذا دعنا نتعامل مع ذلك الآن. أضِف الرمز التالي إلى أسفل 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 تُعرف باسم دليل التحميل:

  • صورة الخلفية 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
    }
  }
}

جرّبه الآن

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

الخاتمة

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