تحتوي الحزمة @tanrei/web على شيئين: ورقة أنماط تضم رموز التصميم (tokens) في صورة خصائص CSS مخصصة، وعناصر مخصصة قياسية مبنية بمكتبة Lit. ولا تشترط أي إطار عمل.
إصدار تمهيدي
نُشر 0.1.0-next.0 على npm تحت وسم التوزيع (dist-tag) next: وحتى 0.1.0، ثبِّت @tanrei/web@next. لا يُحافَظ على التوافق مع الإصدارات السابقة طوال الإصدارات التمهيدية 0.x: قد يكسر أي إصدار تمهيدي أي شيء دون أن يُعلَن شيء مهمَلًا قبله، ويذكر الـ changeset الخاص به ما الذي انكسر.
npm install @tanrei/webلاستعمال الشيفرة المصدرية كما هي على main قبل نشرها، تحتاج إلى Git، و Node 22.22 أو أحدث، و pnpm 12 (الأمر corepack enable يعطيك الإصدار الذي يثبّته المستودع). ابنِ الحزمة في نسخة مستنسخة من المستودع، ثم احزمها، وثبِّت الملف المحزوم في مشروعك:
git clone https://github.com/chnotchy/tanrei.git
cd tanrei
pnpm install
pnpm --filter @tanrei/web build
pnpm --filter @tanrei/web pack --pack-destination ../tanrei-packages# في مشروعك: المسار إلى المجلد الذي كُتبت فيه الحزم
npm install path/to/tanrei-packages/tanrei-web-*.tgzيحمل الملف المحزوم ما ينشره الإصدار، فيعمل كل ما يلي بالطريقة نفسها. ولتأخذ حالة أحدث من الشيفرة المصدرية، اسحب التغييرات (pull) وابنِ واحزم من جديد، ثم ثبِّت الملف الجديد.
-
حمِّل أوراق الأنماط مرة واحدة، وفي أبكر وقت ممكن. كلها موضوعة داخل طبقات تتالٍ (
tanrei.*)، فتكون الغلبة دائمًا لأنماطك أنت.import '@tanrei/web/tanrei.css';الملف
tanrei.cssيضم كل شيء في ملف واحد: أساس الصفحة، والرموز، وأوراق أنماط الأجزاء التي تُرسم من دون عنصر (أنماط النص، والجدول، والأكورديون، وغيرها). ولتحمّل ما تستخدمه وحده، خذ الرموز، والأساس إن أردته، وكل ورقة من تلك الأوراق حين تحتاج إليها:import '@tanrei/web/tokens.css'; import '@tanrei/web/base.css'; // الصفحة بلون سطح النظام ولون نصه وخط المتن، ومن دون هامش على bodyيعطي الأساس الصفحة نفسها لون السطح، ويعطي نصك أنت اللون on-surface وخط المتن، ويزيل الهامش الذي يضعه المتصفح على body. ومن دونه تبقى الصفحة على إعدادات المتصفح الافتراضية.
-
سجِّل مكوّنات نظام التصميم الخاص بك، أو العناصر التي تستخدمها فقط.
import '@tanrei/web'; // في إعداد جاهز، مدخله بدلًا من ذلك: m3-like أو m3-expressive-like أو cupertino import '@tanrei/web/m3-like'; // أو عنصرًا واحدًا في كل مرة: import '@tanrei/web/components/button';لا يسجّل
@tanrei/webإلا المكوّنات التي يتضمنها النظام الافتراضي، فلا يصل المنتج المبني عليه إلى مكوّن لا يملكه نظامه. ويسجّل مدخل الإعداد الجاهز،@tanrei/web/<id>، ما يتضمنه ذلك الإعداد، ويسجّل@tanrei/web/allكل مكوّنات الحزمة. والمكوّن الذي لا يتضمنه إلا إعداد جاهز، مثل الزر المقسَّم، يمكن أخذه مع ذلك عن قصد: ضمّنه في طبقة نظامك واستورد وحدته الخاصة.المحدِّدات من الصيغة
@tanrei/web/…هي صادرات الحزمة (package exports)، ويحلّها المُحزِّم (Vite أو webpack أو esbuild). ومن دون مُحزِّم لا يستطيع المتصفح حلّ محدِّد مجرّد في<link>أو<script src>؛ لذلك قدِّم مجلدdist/من الحزمة وأشِر إلى الملفات مباشرة: أوراق الأنماط كما هي، والملفtanrei.bundle.jsالذي يحمل Lit وجميع العناصر:<link rel="stylesheet" href="/node_modules/@tanrei/web/dist/tanrei.css" /> <script type="module" src="/node_modules/@tanrei/web/dist/tanrei.bundle.js"></script> -
استخدم العناصر كأي عنصر HTML آخر.
<tanrei-button variant="filled">نشر</tanrei-button> <tanrei-button variant="outlined" size="small" disabled>إلغاء</tanrei-button> -
أعطِ كلمات منتجك. ليس للمكوّن كلمات خاصة به: نص وسم المطلوب أو الاختياري، واسم زر الإغلاق، وما شابههما، كلها كلمات منتجك. ولتبدأ بإنجليزية بسيطة لها كلها، استورد حزمة الكلمات الإنجليزية مرة واحدة:
import '@tanrei/web/words/en';ولتغيّر أيًّا منها، أو لتعطي كلمات لغة أخرى، استدعِ
setWords()بعدها. الكلمة التي تعطيها تغلب كلمة الحزمة، والكلمة المعطاة على العنصر تغلب الاثنتين.import { setWords } from '@tanrei/web'; setWords({ requiredText: 'Mandatory' }); setWords({ requiredText: 'مطلوب', optionalText: 'اختياري', closeLabel: 'إغلاق' }, { lang: 'ar' });يعمل المكوّن حتى من دون الحزمة ومن دون كلماتك، ويخبرك في وحدة التحكم (console) بالكلمة التي تنقصه. انظر الكلمات.
مباشر
تقرأ المحررات ووكلاء البرمجة عناصر الحزمة من
بيان العناصر المخصّصة الخاص بها، @tanrei/web/custom-elements.json: كل عنصر بسماته وخصائصه وأحداثه وفتحاته وأجزائه، ولا شيء لا يصل إليه المنتج. ولا يحوي @tanrei/web/custom-elements/<id>.json إلا العناصر التي يتضمنها النظام <id>، فلا تقترح الأداة التي تعمل في نظام واحد شيئًا غيرها.
تستخدم قيم الرموز الدالة light-dark()، فتتبع قيمة color-scheme للعنصر الذي تُستخدم عليه. ومعنى ذلك افتراضيًّا أنها تتبع إعداد نظام التشغيل، وأن prefers-contrast: more ينقلها إلى المخطط اللوني العالي التباين.
للتحكم فيها بنفسك، عيِّن خاصيتَي HTML التاليتين:
<!-- فرض سمة على الصفحة أو على أي شجرة فرعية -->
<html data-tanrei-theme="dark">
<!-- فرض مستوى تباين على الصفحة: "medium" أو "high" -->
<html data-tanrei-contrast="high">ملاحظة
تعمل الخاصيتان كلتاهما على <html> أو على أي شجرة فرعية. تعلن ورقة الأنماط المولَّدة مجموعة الرموز كاملة على :root, [data-tanrei-contrast]، ولذلك فإن وضع data-tanrei-contrast على عنصر داخلي يعيد حساب رموز المكوّنات داخله وفق مستوى التباين ذاك، لا ألوان النظام وحدها.
إن كان الناس يختارون سمة في منتجك، فاحفظ اختيارهم وضعه على <html> قبل أن تُرسم الصفحة أول مرة: فإن وضعه سكربت يعمل بعد ذلك، عرضت الصفحة سمة النظام لإطار واحد أولًا. والوحدة @tanrei/web/theme-script تفعل الأمرين. الدالة themeScript() سكربت صغير يُكتب مضمَّنًا في <head> قبل أوراق الأنماط، والدالة saveTheme() تحفظ الاختيار وترسمه في الحال.
import { loadTheme, saveTheme } from '@tanrei/web/theme-script';
saveTheme('dark'); // 'light' أو 'dark'، أو 'system' للعودة إلى اتباع نظام التشغيل
loadTheme(); // المحفوظ: 'light' أو 'dark' أو 'system'في Next.js (App Router)، اكتبه مضمَّنًا في التخطيط الجذري. والخاصية suppressHydrationWarning موجودة لأن السكربت يغيّر سمة من سمات <html> قبل أن يرطّبه React.
// app/layout.tsx
import { themeScript } from '@tanrei/web/theme-script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript() }} />
</head>
<body>{children}</body>
</html>
);
}وفي Astro، اكتبه مضمَّنًا في التخطيط:
---
// src/layouts/Layout.astro
import { themeScript } from '@tanrei/web/theme-script';
---
<html lang="en">
<head>
<script is:inline set:html={themeScript()} />
</head>
<body><slot /></body>
</html>كل رمز خاصية مخصصة تحمل اسم مساره: tanrei.sys.color.primary يصبح --tanrei-sys-color-primary.
.panel {
background: var(--tanrei-sys-color-surface-container-low);
border-radius: var(--tanrei-sys-shape-corner-medium);
box-shadow: var(--tanrei-sys-elevation-level1);
font: var(--tanrei-sys-typescale-body-font);
}لتضبط رمزًا من سكربت، استخدم setTokens() من @tanrei/web: setTokens({ '--tanrei-sys-color-primary': '#0b57d0' }) على الجذر، أو مع { on: element } لجزء من الصفحة. فضبط رمز واحد وحده على الجذر بالدالة style.setProperty() يجعل Safari يعيد حساب أنماط الصفحة كلها لثوانٍ؛ أما setTokens() فتكتبه بطريقة لا يحدث معها ذلك.
تُسمّى أنماط النص بحسب المستوى (heading-2 هو h2، و body هو p، و label هو النص على عنصر التحكم)، وكل نمط منها مجموعة من الخصائص المخصصة: --tanrei-sys-typescale-<role>-font صيغةً مختصرة، ومعها -font-family و -font-size و -font-weight و -line-height و -letter-spacing و -font-weight-emphasized. وتوجد ورقتا أنماط اختياريتان تطبّقانها على العناصر عنك:
import '@tanrei/web/text.css'; // .tanrei-text-<role>, .tanrei-text-emphasized
import '@tanrei/web/prose.css'; // h1–h6, p, small, code, pre inside .tanrei-prose<h2 class="tanrei-text-heading-2">قسم</h2>
<p class="tanrei-text-body">نص متصل فيه <strong class="tanrei-text-emphasized">توكيد</strong>.</p>
<article class="tanrei-prose"><!-- Markdown بعد تحويله --></article>يرسم الصنف .tanrei-text-emphasized الدور الذي يقع فيه بوزنه مضافًا إليه درجة التوكيد الخاصة بفئته؛ فهو وزن وليس دورًا آخر. الأدوار الستة عشر ومقابلتها بالعناصر في صفحة الطباعة.
يطلب السلّم الطباعي الخطوط Inter و Noto Sans JP و Noto Sans و Roboto Mono (--tanrei-ref-typeface-{brand,plain,mono} في tokens.css؛ ويطبع الأمر tanrei build القائمة لأي نظام)، وتطلب الأيقونات الخط Material Symbols Rounded، وكل ذلك يعود إلى خطوط النظام عند غيابه. لا يضمّ Tanrei ملفات خطوط؛ حمِّلها بنفسك تحت أسماء عائلات الخطوط هذه بالضبط: من Google Fonts، أو من حزم @fontsource-variable/* (وهذه تسجّل الخطوط باسم Inter Variable وما شابه؛ فأعلن @font-face خاصًّا بك بالاسم المجرّد يشير إلى ملفاتها، كما يفعل هذا الموقع).
import '@tanrei/web/icons.css';<tanrei-button><span slot="icon" class="tanrei-icon" aria-hidden="true">add</span>إضافة</tanrei-button>يرسم الصنف .tanrei-icon أيقونة باسمها في Material Symbols بوزن الأيقونات المحدَّد في النظام، ويأخذ حجمه من موضعه. لا تختار أنت بين الأيقونة المحدَّدة الأطراف والمعبَّأة أبدًا: انظر الأيقونات.
يضمّ ملف Material Symbols Rounded الكامل كل الرموز بكل الأوزان والتعبئات والدرجات والأحجام البصرية: 5.4 ميغابايت، وقد تنتظره صفحة على اتصال بطيء نصف دقيقة. حمِّل فقط الرموز التي يرسمها منتجك. يطبع الأمر tanrei build أسماء الرموز التي قد ترسمها مكوّنات Tanrei بنفسها (سهم الشيفرون، وعلامة الاختيار، وعلامة الإغلاق في زر الإغلاق…) وقيم المحاور التي يرسم بها نظامك الأيقونات؛ أضف أسماءك إلى هذه القائمة. عند التحميل من Google Fonts، مرِّر الأسماء مرتّبةً أبجديًا في icon_names= واكتب المحاور نطاقاتٍ في family= (Material+Symbols+Rounded:opsz,wght,FILL,[email protected],300..500,0..1,0 للنظام الافتراضي). وإن كنت تقدّم الملف بنفسك، فاقتطعه بأداة تقتطع مجموعات فرعية تُبقي ميزات الحروف المركّبة في الخط (rlig وrclt) والتعبئتين معًا؛ وهذا الموقع يرسم أيقوناته من ملف كهذا. وفي الحالتين، سجِّل خط الأيقونات مع font-display: block: إلى أن يصل الملف تبقى الأيقونة غير مرئية، ولا يظهر اسمها حروفًا أبدًا.
يعمل Tanrei في هذه الإصدارات وما بعدها:
| المتصفح | الإصدار |
|---|---|
| Chrome و Edge | 125 |
| Firefox | 126 |
| Safari، على macOS و iOS | 17.5 |
كل رقم هو الإصدار الذي جاء بأحدث الميزات التي لا يستطيع Tanrei الرسم من دونها، ولا بديل لأيٍّ منها:
- CSS. كل لون زوجٌ من
light-dark() (Chrome و Edge 123، و Firefox 120، و Safari 17.5). وتُقرَّب المقاسات وأحجام الخط بالدالةround()في CSS (Chrome و Edge 125، و Firefox 118، و Safari 15.4). وتغيّر المكوّنات مظهرها بحسب الحالات المخصّصة:state() (Chrome و Edge 125، و Firefox 126، و Safari 17.4). وتُفتح قوائم الخيارات والتلميحات وأشرطة الإشعار في الطبقة العليا بواجهة Popover API (Chrome و Edge 114، و Firefox 125، و Safari 17). أما طبقات التتالي (cascade layers) التي تُغلَّف بها أوراق الأنماط فمتاحة قبل ذلك (Chrome و Edge 99، و Firefox 97، و Safari 15.4). - JavaScript. الحزمة مكتوبة بـ ES2022، ويفشل بناؤها إن تجاوزها أي ملف تنشره. وأحدث صياغة فيها، كتل static في الأصناف، تعمل منذ Chrome و Edge 94، و Firefox 93، و Safari 16.4. ولا يخفّض تحويلُ الحزمة إلى هدف أقدم الحدَّ الأدنى: فـ CSS المذكورة أعلاه تبقى كما هي.
وبعض الميزات الأحدث لا تُستعمل إلا حيث يظل المتصفح الذي يفتقدها يعمل: من دون @starting-style لا تُعرض حركة الفتح، ومن دون corner-shape تبقى الزاوية مستديرة.
لا يفعل Tanrei ذلك بنفسه: فهذه مهمة نقطة الدخول في منتجك. افحص الميزات أولًا، ولا تحمّل التطبيق، ومعه Tanrei، إلا حين تتوفر:
<p id="unsupported" hidden>يحتاج هذا الموقع إلى Chrome أو Edge 125، أو Firefox 126، أو Safari 17.5، أو إصدار أحدث.</p>
<script type="module">
// light-dark() يحدّد الحد الأدنى لـ Safari، و round() لـ Chrome و Edge، و :state() لـ Firefox
const supported =
CSS.supports('color', 'light-dark(red, red)') &&
CSS.supports('width', 'round(1px, 1px)') &&
CSS.supports('selector(:state(open))');
if (supported) import('./app.js'); // تطبيقك، الذي يستورد @tanrei/web
else document.getElementById('unsupported').hidden = false;
</script>اجعل هذا السكربت صغيرًا وبسيطًا: فمتصفح كل زائر يشغّله، والقديمة منها أيضًا. مع import ثابت، يحمّل المتصفح التطبيق قبل أن يعمل هذا السكربت، والمتصفح القديم الذي لا يستطيع تحليله لا يصل إلى الفحص أصلًا؛ أما import() فلا يحمّله إلا بعد الفحص.
تُبنى عناصر Tanrei على ما تنفّذه المتصفحات: جذور الظل (shadow roots)، وElementInternals (قيمة عنصر التحكم في النموذج وصلاحيتها، والحالات المخصّصة)، وواجهة Popover، و<dialog>، وتخطيط الصفحة. لا تنفّذ jsdom وhappy-dom إلا جزءًا من ذلك (فليس في happy-dom الدالة attachInternals() ولا واجهة Popover)، فقد يرمي العنصر خطأً عند رسمه، أو يترك وعدًا (Promise) مرفوضًا، أو يُرسَم دون الأحجام وترتيب التركيز والطبقة العليا التي يعتمد عليها. لا يدعم Tanrei هذه البيئات، ولا يوفّر بدائل لما ينقصها: فالبديل الذي يمنع العنصر من رمي الخطأ يجعل الاختبار ينجح على سلوك لا يوجد في أي متصفح.
شغّل في متصفح حقيقي كل ما يرسم عنصرًا من عناصر Tanrei:
وضع المتصفح في Vitest، مع مزوّد Playwright، يُبقي اختبارات مكوّناتك في Vitest ويشغّلها في Chromium أو Firefox أو WebKit.- Playwright Test يشغّل الصفحات ومسارات الاستخدام الكاملة.
أما اختبارات منطقك الخاص التي لا ترسم أي عنصر من عناصر Tanrei فيمكن أن تبقى في Node أو jsdom أو happy-dom. وتعمل اختبارات Tanrei نفسها في Chromium وFirefox وWebKit، مع axe-core في Playwright.
تضع معظم العناصر دورها على عنصر أصلي داخل جذر الظل الخاص بها (<button> أو <input> أو <dialog>) لا على نفسها، كي ترى المتصفحات وأدوات الاختبار دلالات حقيقية؛ وقليل منها، كالتبويب أو قائمة الخيارات المفتوحة، يحمل الدور بنفسه. ومحدّدات المواقع (locators) في Playwright تبحث داخل جذور الظل المفتوحة، فابحث عن عنصر التحكم بدوره واسمه، كما يفعل الشخص:
await page.getByRole('button', { name: 'حفظ' }).click();
await expect(page.getByRole('switch', { name: 'Wi-Fi' })).toBeChecked();والنقر على هذا الزر يصل إلى المستمعين المسجّلين على <tanrei-button> نفسه. أما استعلامات Testing Library فلا تبحث داخل جذور الظل، فلا تعثر على عناصر التحكم هذه.
تحتفظ بعض العناصر بدور في جذر الظل الخاص بها، بينما ما ينتمي إليه هو ترميزك أنت، يوضع فيه عبر منفذ (slot): قائمة التبويبات في <tanrei-tabs> وأبناؤها <tanrei-tab>، وعنصر <dialog> في <tanrei-dialog> وإجراءاته. في DOM لا تقع التبويبات داخل قائمة التبويبات، فمحدّد الموقع الذي يتسلسل من الدور لا يعثر على شيء:
// لا يعثر على شيء: التبويبات أبناء <tanrei-tabs>، لا أبناء قائمة تبويباته.
page.getByRole('tablist', { name: 'الأقسام' }).getByRole('tab', { name: 'الفوترة' });اعثر على العنصر الذي يحمل الدور ذا الاسم، ثم ابحث داخله:
const sections = page.locator('tanrei-tabs', { has: page.getByRole('tablist', { name: 'الأقسام' }) });
await sections.getByRole('tab', { name: 'الفوترة' }).click();
const confirm = page.locator('tanrei-dialog', { has: page.getByRole('dialog', { name: 'حذف الملف؟' }) });
await confirm.getByRole('button', { name: 'حذف' }).click();ما دام <tanrei-menu> مفتوحًا فهو قائمة الخيارات، ويحمل اسم مرساته، لكنه لا يشغل مساحة في الصفحة: فسطحه يُرسَم في الطبقة العليا. لذلك يفشل التحقق من ظهور قائمة الخيارات نفسها حتى وهي مفتوحة. تحقّق من وجود قائمة الخيارات، ثم تحقّق من عناصرها:
await page.getByRole('button', { name: 'المزيد' }).click();
const menu = page.getByRole('menu', { name: 'المزيد' });
await expect(menu).toBeAttached();
await expect(menu.getByRole('menuitem', { name: 'إعادة التسمية' })).toBeVisible();وعند إغلاقها لا يبقى العنصر قائمة خيارات، فينجح await expect(menu).toHaveCount(0).
في نسخة التطوير، يكتب العنصر في وحدة التحكم (console) حين يُستخدَم بطريقة لا تسمح بها مواصفته، في رسالة تبدأ بـ [tanrei] وتنتهي بمعرّف السلوك. ويمكن لاختبار في المتصفح أن يفشل عند أي منها: في دليل وكلاء الذكاء الاصطناعي مثال بـ Playwright.