
مرحباً بالجميع ، سوف أخبرك اليوم كيف طورت زر مشروع XMars UI . نعم ، إنه تافه ، لكن هناك شيء يجب أن نخبره. سأحذف التفاصيل المرتبطة بإضافة مكون جديد إلى مشروع مفتوح المصدر. بمزيد من التفصيل سأتحدث عن المشروع في مقال منفصل.
مقدمة
XMars UI هو واحد من المشاريع الجديدة مفتوحة المصدر الخاصة بي. مكتبة بسيطة من مكونات واجهة المستخدم لـ HTML / CSS و React. في المستقبل أخطط لدعم Vue. حتى الآن ، يحتوي فقط على زر وأيقونات :)
وُلد المشروع كفكرة في إطار مسابقة Telegram ، وكان جوهرها تطوير إصدار ويب من العميل. جنبا إلى جنب مع زميل له ، قررنا ، لماذا لا تشارك في هذا. تمت مشاركة الأدوار بحيث يكون لدي تخطيط ، وعندما يفهم أحد الزملاء التفويض ، سأتصل لكتابة المكونات. سيكون كل شيء على ما يرام ، ولكن تسجيل الدخول إلى Telegram ليس بهذه البساطة. نتيجة لذلك ، لم نرسل أي شيء ، لكني قمت بتجميع مجموعة من كل شيء واتضح - بلا جدوى. ولكن كما يقول فارلاموف ، مشروعك يستحق بالفعل شيئًا ما ، لأنك قضيت وقتك في ذلك. من الصعب الاختلاف مع هذا ، لأنه إذا قمت بالتحويل إلى ساعات ومال ، فلن يكون إعداد Webpack في بداية المشروع مجانيًا. بالنظر إلى كل هذا العار ، قررت أن أضعه بطريقة مفتوحة المصدر. ما bootstrap واحد للاستخدام؟ أريد إطار واجهة المستخدم الخاص بي لمشاريعي الأخرى.
ربما يكون الزر الموجود في الواجهة هو العنصر الرئيسي الذي يتفاعل به المستخدم مع التطبيق. لذلك ، يعد هذا أحد المكونات الأولى لأي إطار / مكتبة لواجهة المستخدم.
في تصميم Telegram ، لا يوجد العديد من أشكال الأزرار:

سلطت الضوء على 3 الرئيسية (الافتراضي ، لهجة ، الابتدائي) ، جولة مع رمز والأخضر. لا يزال هناك شبه شفاف ، ولكن ندعه لأسفل. بالنسبة للجزء الأكبر ، تطوير XMars UI أحاول الانتقال من الاحتياجات ، لم أتمكن من معرفة أين ستكون هناك حاجة إلى الزر الشفاف.
يجب أن يكون مستخدم المكتبة مرتاحًا باستخدام فئات CSS. أنا لست من محبي أنظمة التسمية مثل BEM. أنا أحب الطريقة أسماء الطبقات Bootstrap. لكنني سأبسط أكثر من ذلك بقليل. بدلاً من .btn .btn-primary
، فقط .btn .primary
. .btn .primary
. وفي حالة مكون React ، سيبدو كما يلي:
<Button primary>Hey</Button>
نفس الزر ولكن تأثير تموج:
<Button primary ripple>Hey</Button>
HTML / CSS
لا ينبغي ربط مكتبة واجهة المستخدم بأي إطار عمل لواجهة المستخدم. في المستقبل ، أخطط لتمديد التصميم على مكونات Vue
. لنبدأ باستخدام HTML / CSS بسيط.
ضمن غطاء محرك السيارة في مشروع Tailwindcss ، يعد هذا إطار عمل CSS أولًا للأدوات المساعدة ، أي إطارًا يوفر لك أدوات مساعدة ، بدلاً من المكونات الكاملة.

بالإضافة إلى Tailwindcss ، يتم استخدام PostCSS للخلطات والمتغيرات والأنماط المتداخلة.
بمزيد من التفاصيل حول استخدام مثل هذا الإطار وكيفية تكوين المشروع ، سأقول في مقالة منفصلة. في هذه المرحلة ، يكفي أن يكون لدينا مجموعة أدوات قوية واستخدام المكونات التي يتم استخدامها بشكل كامل.
تحتوي <button>
على عدد من الأنماط الافتراضية التي نحتاج إلى إزالتها أو إعادة تعريفها.

في حالة Tailwindcss ، تحتوي علامة الزر على هذا النمط:

جميع لا لزوم لها افتراضيا إزالتها. يمكنك نحت ما تريد دون خوف من أن الحدود الافتراضية سوف تسقط في بعض الحالات. ولكن فيما يلي تحذير ، لا يزال يتعين outline
الافتراضي:

يحتوي زر في XMars UI على فئة .btn
:
<button class="btn">Button</button>
أضف هذه الفئة إلى أنماطنا:
.btn { @apply text-black text-base leading-snug; @apply py-3 px-4 border-none rounded-lg; @apply inline-block cursor-pointer no-underline; &:focus { @apply outline-none; } }
إلى جانب حقيقة أن Tailwindcss يوفر فئات يمكنك استخدامها ، فإنه يوفر نوعًا من mixins
. @apply
ليس @apply
أو نوعًا من البرنامج المساعد لـ PostCSS. هذا هو بناء جملة Tailwindcss نفسه. تكون الأنماط المطبقة واضحة بشكل عام من الاسم. py-3
و px-4
وحدهما يمكن أن يسبب أسئلة. الأول هو الحشو على طول y ، أي عموديًا ، أي padding-top: 0.75rem;
padding-bottom: 0.75rem;
. وبالتالي ، فإن px-4
أفقياً عبارة عن padding-right: 1rem;
، padding-left: 1rem;
.
التصميم الذي قدمته Telegram لوضعه بشكل معتدل موثق بشكل سيئ ويجب أن تؤخذ أشياء مثل أزرار border-radius
مع المسطرة مباشرةً من الصورة. تساءلت يوما ما يعني بالضبط القيم في border-radius
؟

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

border-radius
الأزرار الموجودة في التصميم 10 بكسل ، لسوء الحظ ، لا توجد فئة من المربع في Tailwindcss ، لكنني كنت بصريًا rounded-lg
وهو 8px
لحجم الخط الافتراضي (rem).
إليكم ما حدث في الوقت الحالي ، لقد رسمت الزر باللون الرمادي بحيث يمكن رؤية الحواف:

بعد ذلك ، نحن بحاجة إلى التأثير على :hover
. ثم قرر مصممو Telegram إلقاء بعض الضوء وأشاروا إلى اللون بنسبة 0.08 ٪ من #707579
. أرى خيارين ، ما عليك سوى أخذ اللون باستخدام الشافطة أو القيام بذلك كما هو موثق. الخيار الأول أبسط ، لكنه في المستقبل ليس الأفضل. والحقيقة هي أنه إذا كانت الخلفية مختلفة عن اللون الأبيض ، فعندئذٍ :hover
عند :hover
سنحصل على لون معين ، وسنفقد "خفة" وشفافية الزر. لهذا من الأفضل اتباع الوثائق ووضع ألفا ذكر القناة. يمكن القيام بذلك بطرق لا حصر لها ، على سبيل المثال باستخدام وظائف ألوان SCSS. ولكن لا يوجد SCSS في المشروع ، وبسبب اللون نفسه ، لا أريد توصيل أي مكون إضافي بـ PostCSS ، سنجعل كل شيء بسيطًا للغاية. في Chrome ، هناك أداة colopaker تسمح لك بتحويل الألوان إلى أنظمة مختلفة ، ودفع ألوان HEX #707579
، وترجمتها إلى rgba
وتعيين قناة ألفا - 0.08٪.

فويلا! تومض شيء بحدة صورتي:

نحصل rgba(112, 117, 121, 0.08)
- rgba(112, 117, 121, 0.08)
.

(: تحوم)
مزيد من الممل ودون الكثير من الجهد ، أضفت بقية الدولة:
&:hover { background-color: var(--grey04); } &.accent { color: var(--blue01); } &.primary { @apply text-white; background-color: var(--blue01); &:hover { background-color: var(--blue02); } }
رد الفعل المكون
في البداية ، تم إنشاء الزر لمسابقة Telegram وكان من المستحيل استخدام أي إطار عمل. اضطررت إلى تنفيذ تأثير تموج على JS النقي. أود حقًا أن تظل كذلك ، ولكن أثناء قيامك بالمشروع وحده ، عليك التضحية بشيء ما.
سيتم تنفيذ المكونات التي تتطلب نوعًا ما من المنطق ، مثل تأثير ripple ، وهي متوفرة فقط كمكونات React.
لف الزر الموجود في مكون التفاعل ليس بالأمر الصعب:
import React, { FunctionComponent } from 'react'; export interface ButtonProps { } const Button: FunctionComponent<ButtonProps> = (props) => { return ( <button className="btn">props.children</button> ); } export default Button;
سيتم عرض هذا الزر في النمط المحدد ، لكنه في الحقيقة قليل الفائدة. نحتاج إلى تمكين المستخدم من تخصيص الزر ، وإضافة أنماط مخصصة ، وتعليق معالجات الأحداث ، وما إلى ذلك.
لكي يتمكن المستخدم من نقل كل ما هو ضروري ، يجب أولاً التغلب على Typescript ، وإلا فلن يسمح لك onClick
بالنقل بشكل طبيعي. ButtonProps
تحرير واجهة ButtonProps
قليلاً ، نحل المشكلة:
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
بعد ذلك يمكننا القيام بأمان تدمير props
:
<button className="btn" {...props}>props.children</button>
استخدام مماثل للزر سوف يتصرف كما هو متوقع:
<Button onClick={() => alert()}>Hey</Button>

بعد ذلك ، نضيف أنماط الأزرار والقدرة على تسجيل فئة مخصصة (فجأة سيحتاجها شخص ما). حزمة npm classnames مثالية لهذه الأغراض.
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { primary?: boolean, accent?: boolean, additionalClass?: string, } ... const classNames = classnames( 'btn', { primary }, { accent }, additionalClass ); ... <button className={classNames} {...props}>props.children</button>
يتم دائمًا تعيين فئة btn
، ولكن يتم btn
بشكل primary
ولكن accent
فقط إذا كان ذلك true
. يضيف Classnames فئة إذا كانت لها قيمة true
منطقية ، وباستخدام الاختصار ES6 ، نحصل على إدخال بسيط { primary }
، بدلاً من { primary: true }
.
additionalClass
عبارة عن سلسلة ، وإذا كانت فارغة أو غير محددة ، فإنها لا تلعب دورًا خاصًا بالنسبة لنا ، ولن تتم إضافة أي شيء إلى العنصر.
في البداية ، قمت بتعيين props
النحو التالي:
{...omit(props, ['additionalClass', 'primary'])}
حذف كل ما لا ينطبق على props
الخاصة بعنصر الزر ، ولكن هذا ليس ضروريًا لأن React لن يكون كثيرًا.
تأثير تموج

في الواقع ، هذا ما يبدو عليه ، ولكن من المرغوب فيه أن تتباعد "الموجة" عن مكان النقرة.
هناك طرق لا حصر لها لصنع مثل هذه الرسوم المتحركة ، إنها مثل مزحة حول الساحة الزرقاء .
لكن google ، بالنظر إلى الأمثلة على codepen ، أصبح من الواضح أنه في معظم الحالات ، يتم تحقيق "موجة" من خلال عنصر فرعي ، يتسع ويختفي.
يتم وضعه داخل الزر وفقًا لإحداثيات النقر. في XMars UI ، في الوقت الحالي ، قررت عدم تنفيذ هذا التأثير على onPress
كما تفعل UI Material ، لكنني أخطط onPress
في المستقبل. حتى الآن ، فقط onClick
.

الصورة أعلاه هي كل السحر. تؤدي النقر إلى إنشاء عنصر زر تابع ، ويتم وضعه تمامًا ، في منتصف النقرة ويتوسع. overflow: hidden
الخاصية overflow: hidden
الموجة من تجاوز الزر. يجب حذف العنصر في نهاية الرسوم المتحركة.
أولاً ، سنحدد الأنماط التي يمكننا استخدامها ، باستخدام Tailwindcss قدر الإمكان:
.with-ripple { @apply relative overflow-hidden; @keyframes ripple { to { @apply opacity-0; transform: scale(2.5); } } .ripple { @apply absolute; z-index: 1; border-radius: 50%; background-color: var(--grey04); transform: scale(0); animation: ripple 0.6s linear; } &.primary { .ripple { background-color: var(--black02); } } }
سيتم تعيين العنصر المسؤول عن التأثير في الفصل .ripple
. border-radius: 50%;
يساوي دائرة (50٪ فيليه بزاوية * 2) ، للزر تحديد موقع نسبي ، للزر .ripple
مطلقة. الرسوم المتحركة بسيطة للغاية ، وتصبح الموجة المتزايدة شفافة في 0.6 ثانية. لون الخلفية هو نفسه :hover
للطي ، وهما اللون "الموجي" الشفاف والأزرار تعطينا النتيجة المرجوة. في الزر .primary
الأزرق ، هذا ليس مهمًا للغاية ، ويمكنك استخدام لون غير شفاف.
عند النقر ، تحتاج إلى إنشاء عنصر "الموجة". لذلك ، ننشئ حالة لهذا النشاط التجاري ونضيف معالج النقرات المناسب إلى الزر ، ولكن بطريقة لا تتداخل مع onClick المخصص.
... const [rippleElements, setRippleElements] = useState<JSX.Element[]>([]); ... function renderRippleElements() { return rippleElements; } return ( <button className={classNames} {...props} onClick={(event) => { if (props.onClick) { props.onClick(event); } if (ripple) { onRippleClick(event); } }} > {children} {renderRippleElements()} </button> );
rippleElements
- مجموعة من عناصر JSX ، قد تبدو وظيفة التجسيد هنا زائدة عن الحاجة ، ولكن هذه مسألة أسلوب ودمج للمستقبل.
function onRippleClick(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) { var rect = event.currentTarget.getBoundingClientRect(); const d = Math.max(event.currentTarget.clientWidth, event.currentTarget.clientHeight); const left = event.clientX - rect.left - d/2 + 'px'; const top = event.clientY - rect.top - d/2 + 'px'; const rippleElement = newRippleElement(d, left, top); setRippleElements([...rippleElements, rippleElement]); } function newRippleElement(d: number, left: string, top: string) { const key = uuid(); return ( <div key={key} className="ripple" style={{width: d, height: d, left, top}} onAnimationEnd={() => onAnimationEnd(key)} > </div> ); }
معالج onRippleClick
الذي ينشئ "موجات". بالضغط على الزر ، نكتشف حجم الزر المستخدم لوضع الدائرة بشكل صحيح ، وبعد ذلك يتم تمرير كل ما هو ضروري إلى وظيفة newRippleElement
والتي بدورها تقوم ببساطة بإنشاء عنصر div
مع فئة ripple
، وإنشاء الأنماط اللازمة لتحديد المواقع.
من أهم الأشياء التي تستحق تسليط الضوء onAnimationEnd
. نحتاج إلى هذا الحدث لمسح DOM من العناصر المستخدمة بالفعل.
function onAnimationEnd(key: string) { setRippleElements(rippleElements => rippleElements.filter(element => element.key !== key)); }
من المهم جدًا ألا تنسى ، قم بتمرير rippleElements
الحالي إلى وسيطات ، وإلا يمكنك الحصول على صفيف به قيم قديمة ، ولن يعمل كل شيء بالشكل المقصود.
رمز الزر الكامل:
import React, { FunctionComponent, ButtonHTMLAttributes, useState } from 'react'; import uuid from 'uuid/v4'; import classnames from 'classnames'; export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { primary?: boolean, accent?: boolean, circle?: boolean, ripple?: boolean, additionalClass?: string, } const Button: FunctionComponent<ButtonProps> = (props) => { const [rippleElements, setRippleElements] = useState<JSX.Element[]>([]); const {primary, accent, circle, ripple, additionalClass, children} = props; const classNames = classnames( 'btn', { primary }, { 'with-ripple': ripple }, { circle }, { accent }, additionalClass ); function onAnimationEnd(key: string) { setRippleElements(rippleElements => rippleElements.filter(element => element.key !== key)); } function onRippleClick(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) { var rect = event.currentTarget.getBoundingClientRect(); const d = Math.max(event.currentTarget.clientWidth, event.currentTarget.clientHeight); const left = event.clientX - rect.left - d/2 + 'px'; const top = event.clientY - rect.top - d/2 + 'px'; const rippleElement = newRippleElement(d, left, top); setRippleElements([...rippleElements, rippleElement]); } function newRippleElement(d: number, left: string, top: string) { const key = uuid(); return ( <div key={key} className="ripple" style={{width: d, height: d, left, top}} onAnimationEnd={() => onAnimationEnd(key)} > </div> ); } function renderRippleElements() { return rippleElements; } return ( <button className={classNames} {...props} onClick={(event) => { if (props.onClick) { props.onClick(event); } if (ripple) { onRippleClick(event); } }} > {children} {renderRippleElements()} </button> ); } export default Button;
النتيجة النهائية يمكن الإبلاغ عنها هنا.
استنتاج
تم حذف الكثير ، على سبيل المثال ، كيفية إعداد المشروع ، وكيفية كتابة الوثائق ، واختبار مكون جديد في المشروع. سأحاول تغطية هذه المواضيع بمنشورات منفصلة.
مستودع XMars UI Github