@kashi-ui/react 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,8 +20,8 @@ export function SignUp() {
20
20
  }
21
21
  ```
22
22
 
23
- کامپوننت‌ها: Button · Input · Textarea · Badge · Card · Alert · Avatar · AvatarGroup · Checkbox · Switch
23
+ کامپوننت‌ها: Button · Input · Textarea · Select · Checkbox · RadioGroup · Switch · Badge · Card · Alert · Avatar · AvatarGroup · Tabs · Dialog · Toast · Tooltip · RelativeTime
24
24
 
25
- فارسی: DatePicker و Calendar شمسی · NationalIdInput · CardNumberInput
25
+ فارسی: DatePicker و Calendar شمسی · NationalIdInput · CardNumberInput · IbanInput · MobileInput · PriceInput
26
26
 
27
27
  نیازمند React ۱۸ یا بالاتر. مستندات کامل: [kashi.sepantapouya.com](https://kashi.sepantapouya.com) · کد: [GitHub](https://github.com/Sepantapouya/Kashi) · لایسنس MIT
@@ -0,0 +1,7 @@
1
+ import type { Bank } from "@kashi-ui/persian";
2
+ /** نام و لوگوی بانک داخل فیلد؛ نام با id به فیلد متصل می‌شود و لوگو تزئینی است */
3
+ export declare function BankBadge({ bank, id, showLogo }: {
4
+ bank: Bank | null;
5
+ id: string;
6
+ showLogo: boolean;
7
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ /** لوگوی بانک را فقط هنگام نیاز بارگذاری می‌کند (code-split)، تا حجم اولیهٔ برنامه زیاد نشود */
4
+ function useBankLogo(slug, enabled) {
5
+ const [uri, setUri] = useState(null);
6
+ useEffect(() => {
7
+ if (!slug || !enabled) {
8
+ setUri(null);
9
+ return;
10
+ }
11
+ let alive = true;
12
+ import("@kashi-ui/bank-logos")
13
+ .then((m) => alive && setUri(m.bankLogoDataUri(slug)))
14
+ .catch(() => alive && setUri(null));
15
+ return () => {
16
+ alive = false;
17
+ };
18
+ }, [slug, enabled]);
19
+ return uri;
20
+ }
21
+ /** نام و لوگوی بانک داخل فیلد؛ نام با id به فیلد متصل می‌شود و لوگو تزئینی است */
22
+ export function BankBadge({ bank, id, showLogo }) {
23
+ const logo = useBankLogo(bank?.slug, showLogo);
24
+ if (!bank)
25
+ return null;
26
+ return (_jsxs("span", { className: "k-bank", id: id, children: [logo && _jsx("img", { className: "k-bank__logo", src: logo, alt: "", width: 24, height: 24 }), bank.name] }));
27
+ }
@@ -11,9 +11,11 @@ export interface CardNumberInputProps extends Base {
11
11
  digits?: "fa" | "latin";
12
12
  /** نمایش نام بانک از روی شش رقم اول @default true */
13
13
  showBank?: boolean;
14
+ /** نمایش لوگوی بانک (فقط هنگام نیاز بارگذاری می‌شود) @default true */
15
+ showBankLogo?: boolean;
14
16
  /** @default "شماره کارت معتبر نیست." */
15
17
  invalidMessage?: ReactNode;
16
18
  }
17
- /** فیلد شماره کارت بانکی: گروه‌بندی چهارتایی، تشخیص بانک و بررسی Luhn */
19
+ /** فیلد شماره کارت بانکی: گروه‌بندی چهارتایی، تشخیص بانک با لوگو و بررسی Luhn */
18
20
  export declare const CardNumberInput: import("react").ForwardRefExoticComponent<CardNumberInputProps & import("react").RefAttributes<HTMLInputElement>>;
19
21
  export {};
@@ -1,50 +1,24 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useId, useLayoutEffect, useRef, useState } from "react";
3
- import { formatCardNumber, getBankByCard, isValidCardNumber, onlyDigits, toFaDigits } from "@kashi-ui/persian";
2
+ import { forwardRef, useId, useState } from "react";
3
+ import { formatCardNumber, getBankByCard, isValidCardNumber } from "@kashi-ui/persian";
4
+ import { BankBadge } from "./BankBadge.js";
4
5
  import { Input } from "./Input.js";
6
+ import { useDigitsInput } from "./useDigitsInput.js";
5
7
  import { mergeRefs } from "./utils.js";
6
- /** شمارهٔ ارقام قبل از مکان‌نما را به موقعیت در متن قالب‌بندی‌شده تبدیل می‌کند */
7
- function caretFor(formatted, digitCount) {
8
- if (digitCount === 0)
9
- return 0;
10
- let seen = 0;
11
- for (let i = 0; i < formatted.length; i++) {
12
- if (/[0-9۰-۹]/.test(formatted[i]) && ++seen === digitCount)
13
- return i + 1;
14
- }
15
- return formatted.length;
16
- }
17
- /** فیلد شماره کارت بانکی: گروه‌بندی چهارتایی، تشخیص بانک و بررسی Luhn */
18
- export const CardNumberInput = forwardRef(function CardNumberInput({ value, defaultValue = "", onValueChange, digits = "fa", showBank = true, invalidMessage = "شماره کارت معتبر نیست.", error, name, id, onBlur, placeholder, ...rest }, ref) {
8
+ /** فیلد شماره کارت بانکی: گروه‌بندی چهارتایی، تشخیص بانک با لوگو و بررسی Luhn */
9
+ export const CardNumberInput = forwardRef(function CardNumberInput({ value, defaultValue, onValueChange, digits = "fa", showBank = true, showBankLogo = true, invalidMessage = "شماره کارت معتبر نیست.", error, name, id, onBlur, placeholder, ...rest }, ref) {
19
10
  const autoId = useId();
20
11
  const inputId = id ?? autoId;
21
- const inputRef = useRef(null);
22
- const caret = useRef(null);
23
- const [inner, setInner] = useState(() => onlyDigits(defaultValue).slice(0, 16));
24
12
  const [touched, setTouched] = useState(false);
25
- const raw = value !== undefined ? onlyDigits(value).slice(0, 16) : inner;
26
- const formatted = formatCardNumber(raw);
27
- const shown = digits === "fa" ? toFaDigits(formatted) : formatted;
13
+ const { raw, shown, inputRef, handleChange } = useDigitsInput({ value, defaultValue, maxDigits: 16, format: formatCardNumber, digits });
28
14
  const bank = showBank ? getBankByCard(raw) : null;
29
15
  const showInvalid = raw.length > 0 && (touched || raw.length === 16) && !isValidCardNumber(raw);
30
16
  const bankId = `${inputId}-bank`;
31
- // پس از قالب‌بندی دوباره، مکان‌نما را سر جای منطقی‌اش برمی‌گرداند
32
- useLayoutEffect(() => {
33
- if (caret.current !== null && inputRef.current === document.activeElement) {
34
- const pos = caretFor(shown, caret.current);
35
- inputRef.current?.setSelectionRange(pos, pos);
36
- caret.current = null;
37
- }
38
- });
39
17
  return (_jsxs(_Fragment, { children: [_jsx(Input, { ref: mergeRefs(ref, inputRef), id: inputId, type: "text", inputMode: "numeric", autoComplete: "cc-number", dir: "ltr", placeholder: placeholder ?? (digits === "fa" ? "۰۰۰۰ ۰۰۰۰ ۰۰۰۰ ۰۰۰۰" : "0000 0000 0000 0000"), value: shown, onChange: (e) => {
40
- const el = e.target;
41
- caret.current = onlyDigits(el.value.slice(0, el.selectionStart ?? el.value.length)).length;
42
- const next = onlyDigits(el.value).slice(0, 16);
43
- if (value === undefined)
44
- setInner(next);
18
+ const next = handleChange(e);
45
19
  onValueChange?.(next, isValidCardNumber(next), getBankByCard(next)?.name ?? null);
46
20
  }, onBlur: (e) => {
47
21
  setTouched(true);
48
22
  onBlur?.(e);
49
- }, addonEnd: bank ? _jsx("span", { className: "k-input__meta", id: bankId, children: bank.name }) : undefined, "aria-describedby": bank ? bankId : undefined, error: error ?? (showInvalid ? invalidMessage : undefined), ...rest }), name && _jsx("input", { type: "hidden", name: name, value: raw })] }));
23
+ }, addonEnd: bank ? _jsx(BankBadge, { bank: bank, id: bankId, showLogo: showBankLogo }) : undefined, "aria-describedby": bank ? bankId : undefined, error: error ?? (showInvalid ? invalidMessage : undefined), ...rest }), name && _jsx("input", { type: "hidden", name: name, value: raw })] }));
50
24
  });
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
2
+ import { forwardRef, useEffect, useId, useRef, useState } from "react";
3
3
  import { formatJalali, parseJalali, toISODate } from "@kashi-ui/persian";
4
4
  import { Calendar, sameDay } from "./Calendar.js";
5
5
  import { Input } from "./Input.js";
6
- import { mergeRefs } from "./utils.js";
6
+ import { mergeRefs, useIsomorphicLayoutEffect } from "./utils.js";
7
7
  /**
8
8
  * انتخاب تاریخ شمسی: تایپ مستقیم (۱۴۰۳/۰۵/۱۲) یا انتخاب از تقویم.
9
9
  * با name، مقدار میلادی به شکل YYYY-MM-DD در فرم ارسال می‌شود.
@@ -27,7 +27,7 @@ export const DatePicker = forwardRef(function DatePicker({ value, defaultValue =
27
27
  setInvalid(false);
28
28
  }, [selected?.getTime(), digits]);
29
29
  // تقویم درست زیر کادر فیلد باز می‌شود (نه زیر متن راهنما)
30
- useLayoutEffect(() => {
30
+ useIsomorphicLayoutEffect(() => {
31
31
  if (!open || !wrap.current)
32
32
  return;
33
33
  const box = wrap.current.querySelector(".k-input");
@@ -0,0 +1,27 @@
1
+ import { type ReactNode } from "react";
2
+ export interface DialogProps {
3
+ open: boolean;
4
+ onOpenChange: (open: boolean) => void;
5
+ title: ReactNode;
6
+ description?: ReactNode;
7
+ /** آیکون شاخص بالای عنوان */
8
+ icon?: ReactNode;
9
+ /** «danger» برای تأیید عملیات مخرب */
10
+ tone?: "default" | "danger";
11
+ /** @default "md" */
12
+ size?: "sm" | "md" | "lg";
13
+ /** دکمه‌های پایین پنجره */
14
+ footer?: ReactNode;
15
+ /** بستن با کلیک روی پس‌زمینه @default true */
16
+ dismissible?: boolean;
17
+ /** @default "بستن" */
18
+ closeLabel?: string;
19
+ className?: string;
20
+ children?: ReactNode;
21
+ }
22
+ /**
23
+ * پنجرهٔ گفتگو روی <dialog> بومی با showModal:
24
+ * فوکوس داخل پنجره می‌ماند، Esc می‌بندد، بقیهٔ صفحه از دسترس صفحه‌خوان خارج می‌شود
25
+ * و پس از بستن، فوکوس به عنصری که پنجره را باز کرده برمی‌گردد.
26
+ */
27
+ export declare function Dialog({ open, onOpenChange, title, description, icon, tone, size, footer, dismissible, closeLabel, className, children, }: DialogProps): import("react").JSX.Element;
package/dist/Dialog.js ADDED
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useId, useRef } from "react";
3
+ import { cx } from "./cx.js";
4
+ import { CloseIcon } from "./icons.js";
5
+ /**
6
+ * پنجرهٔ گفتگو روی <dialog> بومی با showModal:
7
+ * فوکوس داخل پنجره می‌ماند، Esc می‌بندد، بقیهٔ صفحه از دسترس صفحه‌خوان خارج می‌شود
8
+ * و پس از بستن، فوکوس به عنصری که پنجره را باز کرده برمی‌گردد.
9
+ */
10
+ export function Dialog({ open, onOpenChange, title, description, icon, tone = "default", size = "md", footer, dismissible = true, closeLabel = "بستن", className, children, }) {
11
+ const ref = useRef(null);
12
+ const opener = useRef(null);
13
+ const id = useId();
14
+ useEffect(() => {
15
+ const d = ref.current;
16
+ if (!d)
17
+ return;
18
+ if (open && !d.open) {
19
+ opener.current = document.activeElement;
20
+ d.showModal();
21
+ }
22
+ else if (!open && d.open) {
23
+ d.close();
24
+ }
25
+ }, [open]);
26
+ return (_jsxs("dialog", { ref: ref, className: cx("k-dialog", size !== "md" && `k-dialog--${size}`, tone === "danger" && "k-dialog--danger", className), "aria-labelledby": `${id}-title`, "aria-describedby": description ? `${id}-desc` : undefined, onClose: () => {
27
+ onOpenChange(false);
28
+ opener.current?.focus?.();
29
+ }, onCancel: (e) => {
30
+ // Esc: بستن از طریق state تا والد همگام بماند
31
+ e.preventDefault();
32
+ onOpenChange(false);
33
+ }, onClick: (e) => {
34
+ // کلیک روی خود <dialog> یعنی کلیک روی پس‌زمینه
35
+ if (dismissible && e.target === ref.current)
36
+ onOpenChange(false);
37
+ }, children: [_jsxs("div", { className: "k-dialog__header", children: [icon && _jsx("span", { className: "k-dialog__icon", "aria-hidden": "true", children: icon }), _jsxs("div", { className: "k-dialog__heading", children: [_jsx("h2", { className: "k-dialog__title", id: `${id}-title`, children: title }), description && _jsx("p", { className: "k-dialog__description", id: `${id}-desc`, children: description })] }), _jsx("button", { type: "button", className: "k-btn k-btn--tertiary k-btn--icon k-btn--sm k-dialog__close", "aria-label": closeLabel, onClick: () => onOpenChange(false), children: _jsx(CloseIcon, {}) })] }), children && _jsx("div", { className: "k-dialog__body", children: children }), footer && _jsx("div", { className: "k-dialog__footer", children: footer })] }));
38
+ }
@@ -0,0 +1,21 @@
1
+ import { type ReactNode } from "react";
2
+ import { type InputProps } from "./Input.js";
3
+ type Base = Omit<InputProps, "value" | "defaultValue" | "onChange" | "type" | "inputMode" | "addonEnd">;
4
+ export interface IbanInputProps extends Base {
5
+ /** مقدار کنترل‌شده: «IR» + ۲۴ رقم یا فقط ۲۴ رقم */
6
+ value?: string;
7
+ defaultValue?: string;
8
+ /** با هر تغییر: شبای کامل «IR…» بدون فاصله، معتبر بودن و نام بانک */
9
+ onValueChange?: (value: string, valid: boolean, bank: string | null) => void;
10
+ /** شبا معمولاً با ارقام لاتین نوشته می‌شود @default "latin" */
11
+ digits?: "fa" | "latin";
12
+ /** @default true */
13
+ showBank?: boolean;
14
+ /** @default true */
15
+ showBankLogo?: boolean;
16
+ /** @default "شماره شبا معتبر نیست." */
17
+ invalidMessage?: ReactNode;
18
+ }
19
+ /** فیلد شماره شبا: IR خودکار، گروه‌بندی، تشخیص بانک با لوگو و بررسی رقم‌های کنترل */
20
+ export declare const IbanInput: import("react").ForwardRefExoticComponent<IbanInputProps & import("react").RefAttributes<HTMLInputElement>>;
21
+ export {};
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useId, useState } from "react";
3
+ import { formatIban, getBankByIban, isValidIban } from "@kashi-ui/persian";
4
+ import { BankBadge } from "./BankBadge.js";
5
+ import { Input } from "./Input.js";
6
+ import { useDigitsInput } from "./useDigitsInput.js";
7
+ import { mergeRefs } from "./utils.js";
8
+ const ibanOf = (d) => (d ? `IR${d}` : "");
9
+ /** فیلد شماره شبا: IR خودکار، گروه‌بندی، تشخیص بانک با لوگو و بررسی رقم‌های کنترل */
10
+ export const IbanInput = forwardRef(function IbanInput({ value, defaultValue, onValueChange, digits = "latin", showBank = true, showBankLogo = true, invalidMessage = "شماره شبا معتبر نیست.", error, name, id, onBlur, placeholder, ...rest }, ref) {
11
+ const autoId = useId();
12
+ const inputId = id ?? autoId;
13
+ const [touched, setTouched] = useState(false);
14
+ const strip = (v) => v?.replace(/^\s*IR/i, "");
15
+ const { raw, shown, inputRef, handleChange } = useDigitsInput({
16
+ value: strip(value),
17
+ defaultValue: strip(defaultValue),
18
+ maxDigits: 24,
19
+ format: (d) => formatIban(d),
20
+ digits,
21
+ });
22
+ const bank = showBank ? getBankByIban(raw) : null;
23
+ const showInvalid = raw.length > 0 && (touched || raw.length === 24) && !isValidIban(raw);
24
+ const bankId = `${inputId}-bank`;
25
+ return (_jsxs(_Fragment, { children: [_jsx(Input, { ref: mergeRefs(ref, inputRef), id: inputId, type: "text", inputMode: "numeric", autoComplete: "off", spellCheck: false, dir: "ltr", placeholder: placeholder ?? "IR00 0000 0000 0000 0000 0000 00", value: shown, onChange: (e) => {
26
+ // «IR» تایپ‌شده یا چسبانده‌شده حذف می‌شود و فقط ارقام می‌مانند
27
+ const next = handleChange(e);
28
+ onValueChange?.(ibanOf(next), isValidIban(next), getBankByIban(next)?.name ?? null);
29
+ }, onBlur: (e) => {
30
+ setTouched(true);
31
+ onBlur?.(e);
32
+ }, addonEnd: bank ? _jsx(BankBadge, { bank: bank, id: bankId, showLogo: showBankLogo }) : undefined, "aria-describedby": bank ? bankId : undefined, error: error ?? (showInvalid ? invalidMessage : undefined), ...rest }), name && _jsx("input", { type: "hidden", name: name, value: ibanOf(raw) })] }));
33
+ });
@@ -0,0 +1,19 @@
1
+ import { type ReactNode } from "react";
2
+ import { type InputProps } from "./Input.js";
3
+ type Base = Omit<InputProps, "value" | "defaultValue" | "onChange" | "type" | "inputMode" | "addonEnd">;
4
+ export interface MobileInputProps extends Base {
5
+ /** مقدار کنترل‌شده، مثل "09123456789" یا "+989123456789" */
6
+ value?: string;
7
+ defaultValue?: string;
8
+ /** با هر تغییر: شماره به شکل ۰۹xxxxxxxxx، معتبر بودن و نام اپراتور */
9
+ onValueChange?: (value: string, valid: boolean, operator: string | null) => void;
10
+ /** @default "fa" */
11
+ digits?: "fa" | "latin";
12
+ /** نمایش نام اپراتور @default true */
13
+ showOperator?: boolean;
14
+ /** @default "شماره موبایل معتبر نیست." */
15
+ invalidMessage?: ReactNode;
16
+ }
17
+ /** فیلد شماره موبایل: +98 و 0098 را به 09 تبدیل می‌کند، گروه‌بندی و تشخیص اپراتور */
18
+ export declare const MobileInput: import("react").ForwardRefExoticComponent<MobileInputProps & import("react").RefAttributes<HTMLInputElement>>;
19
+ export {};
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useId, useState } from "react";
3
+ import { formatMobile, getMobileOperator, isValidMobile, normalizeMobile } from "@kashi-ui/persian";
4
+ import { Input } from "./Input.js";
5
+ import { useDigitsInput } from "./useDigitsInput.js";
6
+ import { mergeRefs } from "./utils.js";
7
+ /** فیلد شماره موبایل: +98 و 0098 را به 09 تبدیل می‌کند، گروه‌بندی و تشخیص اپراتور */
8
+ export const MobileInput = forwardRef(function MobileInput({ value, defaultValue, onValueChange, digits = "fa", showOperator = true, invalidMessage = "شماره موبایل معتبر نیست.", error, name, id, onBlur, placeholder, ...rest }, ref) {
9
+ const autoId = useId();
10
+ const inputId = id ?? autoId;
11
+ const [touched, setTouched] = useState(false);
12
+ const { raw, shown, inputRef, handleChange } = useDigitsInput({
13
+ value,
14
+ defaultValue,
15
+ maxDigits: 11,
16
+ normalize: normalizeMobile,
17
+ format: formatMobile,
18
+ digits,
19
+ });
20
+ const operator = showOperator ? getMobileOperator(raw) : null;
21
+ const showInvalid = raw.length > 0 && (touched || raw.length === 11) && !isValidMobile(raw);
22
+ const opId = `${inputId}-operator`;
23
+ return (_jsxs(_Fragment, { children: [_jsx(Input, { ref: mergeRefs(ref, inputRef), id: inputId, type: "tel", inputMode: "tel", autoComplete: "tel-national", dir: "ltr", placeholder: placeholder ?? (digits === "fa" ? "۰۹۱۲ ۳۴۵ ۶۷۸۹" : "0912 345 6789"), value: shown, onChange: (e) => {
24
+ const next = handleChange(e);
25
+ onValueChange?.(next, isValidMobile(next), getMobileOperator(next)?.name ?? null);
26
+ }, onBlur: (e) => {
27
+ setTouched(true);
28
+ onBlur?.(e);
29
+ }, addonEnd: operator ? _jsx("span", { className: "k-input__meta", id: opId, children: operator.name }) : undefined, "aria-describedby": operator ? opId : undefined, error: error ?? (showInvalid ? invalidMessage : undefined), ...rest }), name && _jsx("input", { type: "hidden", name: name, value: raw })] }));
30
+ });
@@ -0,0 +1,21 @@
1
+ import { type ReactNode } from "react";
2
+ import { type InputProps } from "./Input.js";
3
+ type Base = Omit<InputProps, "value" | "defaultValue" | "onChange" | "type" | "inputMode" | "addonEnd" | "min" | "max">;
4
+ export interface PriceInputProps extends Base {
5
+ value?: number | null;
6
+ defaultValue?: number | null;
7
+ onValueChange?: (value: number | null) => void;
8
+ /** واحد پول کنار فیلد @default "تومان" */
9
+ unit?: ReactNode;
10
+ /** نمایش مبلغ به حروف زیر فیلد @default true */
11
+ showWords?: boolean;
12
+ /** @default "fa" */
13
+ digits?: "fa" | "latin";
14
+ min?: number;
15
+ max?: number;
16
+ /** پیام خطای بیرون از محدوده */
17
+ rangeMessage?: (min?: number, max?: number) => ReactNode;
18
+ }
19
+ /** فیلد مبلغ: جداکنندهٔ هزارگان، واحد پول و نمایش مبلغ به حروف برای اطمینان کاربر */
20
+ export declare const PriceInput: import("react").ForwardRefExoticComponent<PriceInputProps & import("react").RefAttributes<HTMLInputElement>>;
21
+ export {};
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useState } from "react";
3
+ import { formatNumber, numberToWords, toFaDigits } from "@kashi-ui/persian";
4
+ import { Input } from "./Input.js";
5
+ import { useDigitsInput } from "./useDigitsInput.js";
6
+ import { mergeRefs } from "./utils.js";
7
+ const toRaw = (n) => (n === null || n === undefined ? undefined : String(Math.trunc(Math.abs(n))));
8
+ /** فیلد مبلغ: جداکنندهٔ هزارگان، واحد پول و نمایش مبلغ به حروف برای اطمینان کاربر */
9
+ export const PriceInput = forwardRef(function PriceInput({ value, defaultValue, onValueChange, unit = "تومان", showWords = true, digits = "fa", min, max, rangeMessage = (lo, hi) => `مبلغ باید ${lo !== undefined ? `حداقل ${toFaDigits(formatNumber(lo))}` : ""}${lo !== undefined && hi !== undefined ? " و " : ""}${hi !== undefined ? `حداکثر ${toFaDigits(formatNumber(hi))}` : ""} باشد.`, error, hint, name, onBlur, placeholder, ...rest }, ref) {
10
+ const [touched, setTouched] = useState(false);
11
+ const { raw, shown, inputRef, handleChange } = useDigitsInput({
12
+ value: value === undefined ? undefined : (toRaw(value) ?? ""),
13
+ defaultValue: toRaw(defaultValue),
14
+ maxDigits: 15,
15
+ normalize: (d) => d.replace(/^0+(?=\d)/, ""),
16
+ format: (d) => (d ? formatNumber(d, { digits: "latin" }).replace(/,/g, "٬") : ""),
17
+ digits,
18
+ });
19
+ const n = raw ? Number(raw) : null;
20
+ const outOfRange = n !== null && ((min !== undefined && n < min) || (max !== undefined && n > max));
21
+ const words = showWords && n !== null ? `${numberToWords(n)}${typeof unit === "string" ? ` ${unit}` : ""}` : null;
22
+ return (_jsxs(_Fragment, { children: [_jsx(Input, { ref: mergeRefs(ref, inputRef), type: "text", inputMode: "numeric", autoComplete: "off", dir: "ltr", placeholder: placeholder ?? (digits === "fa" ? "۰" : "0"), value: shown, onChange: (e) => {
23
+ const next = handleChange(e);
24
+ onValueChange?.(next ? Number(next) : null);
25
+ }, onBlur: (e) => {
26
+ setTouched(true);
27
+ onBlur?.(e);
28
+ }, addonEnd: unit ? _jsx("span", { className: "k-input__meta", children: unit }) : undefined, hint: words ?? hint, error: error ?? (touched && outOfRange ? rangeMessage(min, max) : undefined), ...rest }), name && _jsx("input", { type: "hidden", name: name, value: raw })] }));
29
+ });
@@ -0,0 +1,26 @@
1
+ import { type InputHTMLAttributes, type ReactNode } from "react";
2
+ export interface RadioGroupProps {
3
+ /** عنوان گروه (legend) */
4
+ label: ReactNode;
5
+ /** نام فیلد فرم؛ پیش‌فرض خودکار */
6
+ name?: string;
7
+ value?: string;
8
+ defaultValue?: string;
9
+ onChange?: (value: string) => void;
10
+ /** «list» (پیش‌فرض)، «inline» افقی، «cards» کارت‌های قابل‌انتخاب */
11
+ variant?: "list" | "inline" | "cards";
12
+ hint?: ReactNode;
13
+ error?: ReactNode;
14
+ required?: boolean;
15
+ disabled?: boolean;
16
+ className?: string;
17
+ children: ReactNode;
18
+ }
19
+ /** گروه رادیو با fieldset و legend؛ پیمایش با کلیدهای جهت‌دار را مرورگر انجام می‌دهد */
20
+ export declare function RadioGroup({ label, name, value, defaultValue, onChange, variant, hint, error, required, disabled, className, children, }: RadioGroupProps): import("react").JSX.Element;
21
+ export interface RadioProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value"> {
22
+ value: string;
23
+ label: ReactNode;
24
+ description?: ReactNode;
25
+ }
26
+ export declare const Radio: import("react").ForwardRefExoticComponent<RadioProps & import("react").RefAttributes<HTMLInputElement>>;
package/dist/Radio.js ADDED
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, forwardRef, useContext, useId, useState } from "react";
3
+ import { cx } from "./cx.js";
4
+ import { isDev } from "./utils.js";
5
+ const RadioCtx = createContext(null);
6
+ /** گروه رادیو با fieldset و legend؛ پیمایش با کلیدهای جهت‌دار را مرورگر انجام می‌دهد */
7
+ export function RadioGroup({ label, name, value, defaultValue, onChange, variant = "list", hint, error, required, disabled, className, children, }) {
8
+ const id = useId();
9
+ const [inner, setInner] = useState(defaultValue);
10
+ const current = value !== undefined ? value : inner;
11
+ const describedBy = [error && `${id}-error`, hint && `${id}-hint`].filter(Boolean).join(" ") || undefined;
12
+ return (_jsxs("fieldset", { className: cx("k-radio-group", variant !== "list" && `k-radio-group--${variant}`, className), "aria-describedby": describedBy, "aria-required": required || undefined, "aria-invalid": error ? true : undefined, disabled: disabled, children: [_jsxs("legend", { className: "k-label", children: [label, required && _jsx("span", { className: "k-label__required", "aria-hidden": "true", children: "*" })] }), _jsx(RadioCtx.Provider, { value: {
13
+ name: name ?? id,
14
+ value: current,
15
+ invalid: !!error,
16
+ disabled,
17
+ setValue: (v) => {
18
+ if (value === undefined)
19
+ setInner(v);
20
+ onChange?.(v);
21
+ },
22
+ }, children: children }), error && _jsx("p", { className: "k-hint k-hint--error", id: `${id}-error`, children: error }), hint && _jsx("p", { className: "k-hint", id: `${id}-hint`, children: hint })] }));
23
+ }
24
+ export const Radio = forwardRef(function Radio({ value, label, description, id, className, disabled, name, ...rest }, ref) {
25
+ const ctx = useContext(RadioCtx);
26
+ // بدون name مشترک، مرورگر رادیوها را یک گروه نمی‌داند و چند گزینه هم‌زمان انتخاب می‌شود
27
+ if (!ctx && !name && isDev()) {
28
+ console.warn("[kashi] <Radio> باید داخل <RadioGroup> باشد یا name داشته باشد؛ وگرنه چند گزینه هم‌زمان قابل‌انتخاب است.");
29
+ }
30
+ const autoId = useId();
31
+ const inputId = id ?? autoId;
32
+ return (_jsxs("label", { className: cx("k-check", className), htmlFor: inputId, children: [_jsx("input", { ref: ref, type: "radio", className: "k-radio", id: inputId, name: ctx?.name ?? name, value: value, checked: ctx ? ctx.value === value : undefined, onChange: () => ctx?.setValue(value), disabled: disabled, "aria-describedby": description ? `${inputId}-desc` : undefined, ...rest }), _jsxs("span", { className: "k-check__text", children: [_jsx("span", { className: "k-check__label", children: label }), description && _jsx("span", { className: "k-check__description", id: `${inputId}-desc`, children: description })] })] }));
33
+ });
@@ -0,0 +1,10 @@
1
+ import { type HTMLAttributes } from "react";
2
+ export interface RelativeTimeProps extends Omit<HTMLAttributes<HTMLTimeElement>, "children"> {
3
+ date: Date;
4
+ /** @default "fa" */
5
+ digits?: "fa" | "latin";
6
+ /** فاصلهٔ به‌روزرسانی بر حسب میلی‌ثانیه @default 60000 */
7
+ refresh?: number;
8
+ }
9
+ /** «۵ دقیقه پیش» در یک <time> معنایی؛ تاریخ کامل شمسی در title و برای صفحه‌خوان در دسترس است */
10
+ export declare function RelativeTime({ date, digits, refresh, ...rest }: RelativeTimeProps): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { formatJalali, formatRelative } from "@kashi-ui/persian";
4
+ /** «۵ دقیقه پیش» در یک <time> معنایی؛ تاریخ کامل شمسی در title و برای صفحه‌خوان در دسترس است */
5
+ export function RelativeTime({ date, digits = "fa", refresh = 60000, ...rest }) {
6
+ const [now, setNow] = useState(() => new Date());
7
+ useEffect(() => {
8
+ const t = setInterval(() => setNow(new Date()), refresh);
9
+ return () => clearInterval(t);
10
+ }, [refresh]);
11
+ return (_jsx("time", { dateTime: date.toISOString(), title: formatJalali(date, { style: "full", digits }), ...rest, children: formatRelative(date, { now, digits }) }));
12
+ }
@@ -0,0 +1,20 @@
1
+ import { type ReactNode, type SelectHTMLAttributes } from "react";
2
+ export interface SelectOption {
3
+ value: string;
4
+ label: ReactNode;
5
+ disabled?: boolean;
6
+ }
7
+ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> {
8
+ label?: ReactNode;
9
+ hint?: ReactNode;
10
+ error?: ReactNode;
11
+ /** گزینهٔ خالی اول، مثل «انتخاب کنید» */
12
+ placeholder?: string;
13
+ /** گزینه‌ها؛ یا به‌جای آن <option> را به‌عنوان children بدهید */
14
+ options?: SelectOption[];
15
+ /** @default "md" */
16
+ size?: "sm" | "md" | "lg";
17
+ fieldClassName?: string;
18
+ }
19
+ /** فهرست انتخابی روی <select> بومی: در گوشی انتخاب‌گر سیستم باز می‌شود و صفحه‌خوان کامل پشتیبانی می‌کند */
20
+ export declare const Select: import("react").ForwardRefExoticComponent<SelectProps & import("react").RefAttributes<HTMLSelectElement>>;
package/dist/Select.js ADDED
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useId } from "react";
3
+ import { cx } from "./cx.js";
4
+ import { Field, describedBy } from "./Field.js";
5
+ /** فهرست انتخابی روی <select> بومی: در گوشی انتخاب‌گر سیستم باز می‌شود و صفحه‌خوان کامل پشتیبانی می‌کند */
6
+ export const Select = forwardRef(function Select({ label, hint, error, placeholder, options, size = "md", id, required, className, fieldClassName, children, "aria-describedby": extra, ...rest }, ref) {
7
+ const autoId = useId();
8
+ const selectId = id ?? autoId;
9
+ const placeholderSelected = placeholder !== undefined && rest.value === undefined && rest.defaultValue === undefined;
10
+ return (_jsx(Field, { id: selectId, label: label, hint: hint, error: error, required: required, className: fieldClassName, children: _jsx("div", { className: cx("k-select", size !== "md" && `k-select--${size}`, className), children: _jsxs("select", { ref: ref, id: selectId, className: "k-select__control", required: required, "aria-invalid": error ? true : undefined, "aria-describedby": describedBy(selectId, hint, error, extra), defaultValue: placeholderSelected ? "" : undefined, ...rest, children: [placeholder !== undefined && _jsx("option", { value: "", disabled: true, children: placeholder }), options?.map((o) => (_jsx("option", { value: o.value, disabled: o.disabled, children: o.label }, o.value))), children] }) }) }));
11
+ });
package/dist/Tabs.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { type HTMLAttributes, type ReactNode } from "react";
2
+ export interface TabsProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue"> {
3
+ value?: string;
4
+ defaultValue?: string;
5
+ onChange?: (value: string) => void;
6
+ /** @default "line" */
7
+ variant?: "line" | "pill" | "segmented";
8
+ /** زبانه‌ها تمام عرض را پر کنند */
9
+ block?: boolean;
10
+ }
11
+ /** زبانه‌ها با الگوی Tabs در WAI-ARIA؛ کلیدهای جهت‌دار در راست‌به‌چپ برعکس عمل می‌کنند */
12
+ export declare function Tabs({ value, defaultValue, onChange, variant, block, className, children, ...rest }: TabsProps): import("react").JSX.Element;
13
+ export interface TabListProps extends HTMLAttributes<HTMLDivElement> {
14
+ /** نام فهرست برای صفحه‌خوان، مثل «تنظیمات حساب» */
15
+ "aria-label": string;
16
+ }
17
+ export declare function TabList({ className, children, ...rest }: TabListProps): import("react").JSX.Element;
18
+ export interface TabProps extends Omit<HTMLAttributes<HTMLButtonElement>, "value"> {
19
+ value: string;
20
+ disabled?: boolean;
21
+ }
22
+ export declare function Tab({ value, className, children, disabled, ...rest }: TabProps): import("react").JSX.Element;
23
+ export interface TabPanelProps extends HTMLAttributes<HTMLDivElement> {
24
+ value: string;
25
+ children: ReactNode;
26
+ }
27
+ export declare function TabPanel({ value, className, children, ...rest }: TabPanelProps): import("react").JSX.Element;
package/dist/Tabs.js ADDED
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useId, useRef, useState } from "react";
3
+ import { cx } from "./cx.js";
4
+ const TabsCtx = createContext(null);
5
+ const useTabs = () => {
6
+ const ctx = useContext(TabsCtx);
7
+ if (!ctx)
8
+ throw new Error("[kashi] Tab و TabPanel باید داخل <Tabs> باشند.");
9
+ return ctx;
10
+ };
11
+ /** زبانه‌ها با الگوی Tabs در WAI-ARIA؛ کلیدهای جهت‌دار در راست‌به‌چپ برعکس عمل می‌کنند */
12
+ export function Tabs({ value, defaultValue = "", onChange, variant = "line", block, className, children, ...rest }) {
13
+ const id = useId();
14
+ const [inner, setInner] = useState(defaultValue);
15
+ const selected = value !== undefined ? value : inner;
16
+ return (_jsx(TabsCtx.Provider, { value: {
17
+ id,
18
+ selected,
19
+ select: (v) => {
20
+ if (value === undefined)
21
+ setInner(v);
22
+ onChange?.(v);
23
+ },
24
+ }, children: _jsx("div", { className: cx("k-tabs", `k-tabs--${variant}`, block && "k-tabs--block", className), ...rest, children: children }) }));
25
+ }
26
+ export function TabList({ className, children, ...rest }) {
27
+ const ref = useRef(null);
28
+ const onKeyDown = (e) => {
29
+ const tabs = [...(ref.current?.querySelectorAll('[role="tab"]:not(:disabled)') ?? [])];
30
+ const i = tabs.indexOf(document.activeElement);
31
+ if (i < 0)
32
+ return;
33
+ const rtl = getComputedStyle(ref.current).direction === "rtl";
34
+ const moves = {
35
+ ArrowLeft: rtl ? i + 1 : i - 1,
36
+ ArrowRight: rtl ? i - 1 : i + 1,
37
+ Home: 0,
38
+ End: tabs.length - 1,
39
+ };
40
+ if (!(e.key in moves))
41
+ return;
42
+ e.preventDefault();
43
+ const next = tabs[(moves[e.key] + tabs.length) % tabs.length];
44
+ next.focus();
45
+ next.click(); // فعال‌سازی خودکار با فوکوس
46
+ };
47
+ return (_jsx("div", { ref: ref, role: "tablist", className: cx("k-tabs__list", className), onKeyDown: onKeyDown, ...rest, children: children }));
48
+ }
49
+ export function Tab({ value, className, children, disabled, ...rest }) {
50
+ const { id, selected, select } = useTabs();
51
+ const on = selected === value;
52
+ return (_jsx("button", { type: "button", role: "tab", id: `${id}-tab-${value}`, "aria-selected": on, "aria-controls": `${id}-panel-${value}`, tabIndex: on ? 0 : -1, disabled: disabled, className: cx("k-tabs__tab", className), onClick: () => select(value), ...rest, children: children }));
53
+ }
54
+ export function TabPanel({ value, className, children, ...rest }) {
55
+ const { id, selected } = useTabs();
56
+ return (_jsx("div", { role: "tabpanel", id: `${id}-panel-${value}`, "aria-labelledby": `${id}-tab-${value}`, hidden: selected !== value, tabIndex: 0, className: cx("k-tabs__panel", className), ...rest, children: children }));
57
+ }
@@ -0,0 +1,32 @@
1
+ import { type ReactNode } from "react";
2
+ export type ToastTone = "neutral" | "success" | "error" | "warning" | "info";
3
+ export interface ToastOptions {
4
+ title: ReactNode;
5
+ description?: ReactNode;
6
+ tone?: ToastTone;
7
+ /** میلی‌ثانیه؛ Infinity برای ماندن تا بستن دستی. پیش‌فرض ۵ ثانیه، و بی‌نهایت اگر action داشته باشد */
8
+ duration?: number;
9
+ action?: {
10
+ label: string;
11
+ onClick: () => void;
12
+ };
13
+ }
14
+ /** نمایش اعلان کوتاه؛ شناسهٔ آن را برمی‌گرداند */
15
+ export declare function toast(options: ToastOptions | string): number;
16
+ export declare namespace toast {
17
+ var success: (title: ReactNode, o?: Omit<ToastOptions, "title" | "tone">) => number;
18
+ var error: (title: ReactNode, o?: Omit<ToastOptions, "title" | "tone">) => number;
19
+ var warning: (title: ReactNode, o?: Omit<ToastOptions, "title" | "tone">) => number;
20
+ var info: (title: ReactNode, o?: Omit<ToastOptions, "title" | "tone">) => number;
21
+ var dismiss: (id: number) => void;
22
+ }
23
+ export interface ToasterProps {
24
+ /** @default "bottom" */
25
+ position?: "top" | "bottom";
26
+ /** @default "اعلان‌ها" */
27
+ label?: string;
28
+ /** @default "بستن اعلان" */
29
+ closeLabel?: string;
30
+ }
31
+ /** محل نمایش اعلان‌ها؛ یک بار در ریشهٔ برنامه قرار دهید */
32
+ export declare function Toaster({ position, label, closeLabel }: ToasterProps): import("react").JSX.Element;
package/dist/Toast.js ADDED
@@ -0,0 +1,65 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
3
+ import { cx } from "./cx.js";
4
+ import { CloseIcon, ErrorIcon, InfoIcon, SuccessIcon, WarningIcon } from "./icons.js";
5
+ // مخزن سادهٔ سراسری؛ هر جای برنامه می‌تواند toast() را صدا بزند
6
+ let items = [];
7
+ let nextId = 1;
8
+ const listeners = new Set();
9
+ const emit = () => listeners.forEach((l) => l());
10
+ const subscribe = (l) => {
11
+ listeners.add(l);
12
+ return () => listeners.delete(l);
13
+ };
14
+ function dismiss(id) {
15
+ items = items.map((t) => (t.id === id ? { ...t, closing: true } : t));
16
+ emit();
17
+ setTimeout(() => {
18
+ items = items.filter((t) => t.id !== id);
19
+ emit();
20
+ }, 200);
21
+ }
22
+ /** نمایش اعلان کوتاه؛ شناسهٔ آن را برمی‌گرداند */
23
+ export function toast(options) {
24
+ const o = typeof options === "string" ? { title: options } : options;
25
+ const id = nextId++;
26
+ items = [...items, { ...o, id }];
27
+ emit();
28
+ return id;
29
+ }
30
+ toast.success = (title, o = {}) => toast({ ...o, title, tone: "success" });
31
+ toast.error = (title, o = {}) => toast({ ...o, title, tone: "error" });
32
+ toast.warning = (title, o = {}) => toast({ ...o, title, tone: "warning" });
33
+ toast.info = (title, o = {}) => toast({ ...o, title, tone: "info" });
34
+ toast.dismiss = dismiss;
35
+ const ICONS = {
36
+ neutral: InfoIcon, info: InfoIcon, success: SuccessIcon, warning: WarningIcon, error: ErrorIcon,
37
+ };
38
+ function ToastView({ t, closeLabel }) {
39
+ const duration = t.duration ?? (t.action ? Infinity : 5000);
40
+ const remaining = useRef(duration);
41
+ const started = useRef(0);
42
+ const timer = useRef();
43
+ const [paused, setPaused] = useState(false);
44
+ // زمان‌سنج با توقف هنگام hover یا فوکوس (WCAG 2.2.1)
45
+ useEffect(() => {
46
+ if (!Number.isFinite(duration) || paused || t.closing)
47
+ return;
48
+ started.current = Date.now();
49
+ timer.current = setTimeout(() => dismiss(t.id), remaining.current);
50
+ return () => {
51
+ clearTimeout(timer.current);
52
+ remaining.current -= Date.now() - started.current;
53
+ };
54
+ }, [paused, t.closing]);
55
+ const Icon = ICONS[t.tone ?? "neutral"];
56
+ return (_jsxs("li", { className: cx("k-toast", t.tone && t.tone !== "neutral" && `k-toast--${t.tone}`), "data-state": t.closing ? "closing" : "open", role: t.tone === "error" ? "alert" : "status", onMouseEnter: () => setPaused(true), onMouseLeave: () => setPaused(false), onFocus: () => setPaused(true), onBlur: () => setPaused(false), onKeyDown: (e) => e.key === "Escape" && dismiss(t.id), children: [_jsx("span", { className: "k-toast__icon", "aria-hidden": "true", children: _jsx(Icon, {}) }), _jsxs("div", { className: "k-toast__content", children: [_jsx("p", { className: "k-toast__title", children: t.title }), t.description && _jsx("p", { className: "k-toast__description", children: t.description }), t.action && (_jsx("button", { type: "button", className: "k-btn k-btn--link k-toast__action", onClick: () => {
57
+ t.action.onClick();
58
+ dismiss(t.id);
59
+ }, children: t.action.label }))] }), _jsx("button", { type: "button", className: "k-btn k-btn--tertiary k-btn--icon k-btn--sm k-toast__close", "aria-label": closeLabel, onClick: () => dismiss(t.id), children: _jsx(CloseIcon, {}) }), Number.isFinite(duration) && _jsx("span", { className: "k-toast__progress", "aria-hidden": "true", style: { animationDuration: `${duration}ms` } })] }));
60
+ }
61
+ /** محل نمایش اعلان‌ها؛ یک بار در ریشهٔ برنامه قرار دهید */
62
+ export function Toaster({ position = "bottom", label = "اعلان‌ها", closeLabel = "بستن اعلان" }) {
63
+ const list = useSyncExternalStore(subscribe, () => items, () => items);
64
+ return (_jsx("section", { "aria-label": label, children: _jsx("ol", { className: cx("k-toaster", position === "top" && "k-toaster--top"), "aria-live": "polite", children: list.map((t) => _jsx(ToastView, { t: t, closeLabel: closeLabel }, t.id)) }) }));
65
+ }
@@ -0,0 +1,16 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ export interface TooltipProps {
3
+ /** متن راهنما؛ فقط توضیح تکمیلی، نه اطلاعات ضروری */
4
+ content: ReactNode;
5
+ /** عنصر فوکوس‌پذیر، معمولاً یک دکمه */
6
+ children: ReactElement;
7
+ /** @default "top" */
8
+ placement?: "top" | "bottom";
9
+ /** تأخیر نمایش با hover بر حسب میلی‌ثانیه @default 300 */
10
+ delay?: number;
11
+ }
12
+ /**
13
+ * راهنمای کوتاه طبق WCAG 1.4.13: با hover و فوکوس نمایش داده می‌شود، روی خودش هم hover پذیر است
14
+ * و با Esc بسته می‌شود. با aria-describedby به عنصر متصل است.
15
+ */
16
+ export declare function Tooltip({ content, children, placement, delay }: TooltipProps): import("react").JSX.Element;
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cloneElement, isValidElement, useEffect, useId, useRef, useState } from "react";
3
+ import { cx } from "./cx.js";
4
+ /**
5
+ * راهنمای کوتاه طبق WCAG 1.4.13: با hover و فوکوس نمایش داده می‌شود، روی خودش هم hover پذیر است
6
+ * و با Esc بسته می‌شود. با aria-describedby به عنصر متصل است.
7
+ */
8
+ export function Tooltip({ content, children, placement = "top", delay = 300 }) {
9
+ const id = useId();
10
+ const [open, setOpen] = useState(false);
11
+ const timer = useRef();
12
+ const show = (wait) => {
13
+ clearTimeout(timer.current);
14
+ timer.current = setTimeout(() => setOpen(true), wait);
15
+ };
16
+ const hide = () => {
17
+ clearTimeout(timer.current);
18
+ timer.current = setTimeout(() => setOpen(false), 100);
19
+ };
20
+ useEffect(() => {
21
+ if (!open)
22
+ return;
23
+ const onKey = (e) => e.key === "Escape" && setOpen(false);
24
+ document.addEventListener("keydown", onKey);
25
+ return () => document.removeEventListener("keydown", onKey);
26
+ }, [open]);
27
+ useEffect(() => () => clearTimeout(timer.current), []);
28
+ const child = isValidElement(children)
29
+ ? cloneElement(children, {
30
+ "aria-describedby": [children.props["aria-describedby"], id].filter(Boolean).join(" "),
31
+ })
32
+ : children;
33
+ return (_jsxs("span", { className: "k-tooltip-anchor", onMouseEnter: () => show(delay), onMouseLeave: hide, onFocus: () => show(0), onBlur: hide, children: [child, _jsx("span", { role: "tooltip", id: id, className: cx("k-tooltip", placement === "bottom" && "k-tooltip--bottom"), hidden: !open, children: content })] }));
34
+ }
package/dist/index.d.ts CHANGED
@@ -22,3 +22,23 @@ export { Calendar } from "./Calendar.js";
22
22
  export type { CalendarProps, CalendarLabels } from "./Calendar.js";
23
23
  export { DatePicker } from "./DatePicker.js";
24
24
  export type { DatePickerProps } from "./DatePicker.js";
25
+ export { IbanInput } from "./IbanInput.js";
26
+ export type { IbanInputProps } from "./IbanInput.js";
27
+ export { MobileInput } from "./MobileInput.js";
28
+ export type { MobileInputProps } from "./MobileInput.js";
29
+ export { PriceInput } from "./PriceInput.js";
30
+ export type { PriceInputProps } from "./PriceInput.js";
31
+ export { RadioGroup, Radio } from "./Radio.js";
32
+ export type { RadioGroupProps, RadioProps } from "./Radio.js";
33
+ export { Select } from "./Select.js";
34
+ export type { SelectProps, SelectOption } from "./Select.js";
35
+ export { Tabs, TabList, Tab, TabPanel } from "./Tabs.js";
36
+ export type { TabsProps, TabListProps, TabProps, TabPanelProps } from "./Tabs.js";
37
+ export { Dialog } from "./Dialog.js";
38
+ export type { DialogProps } from "./Dialog.js";
39
+ export { Toaster, toast } from "./Toast.js";
40
+ export type { ToasterProps, ToastOptions, ToastTone } from "./Toast.js";
41
+ export { Tooltip } from "./Tooltip.js";
42
+ export type { TooltipProps } from "./Tooltip.js";
43
+ export { RelativeTime } from "./RelativeTime.js";
44
+ export type { RelativeTimeProps } from "./RelativeTime.js";
package/dist/index.js CHANGED
@@ -11,3 +11,13 @@ export { NationalIdInput } from "./NationalIdInput.js";
11
11
  export { CardNumberInput } from "./CardNumberInput.js";
12
12
  export { Calendar } from "./Calendar.js";
13
13
  export { DatePicker } from "./DatePicker.js";
14
+ export { IbanInput } from "./IbanInput.js";
15
+ export { MobileInput } from "./MobileInput.js";
16
+ export { PriceInput } from "./PriceInput.js";
17
+ export { RadioGroup, Radio } from "./Radio.js";
18
+ export { Select } from "./Select.js";
19
+ export { Tabs, TabList, Tab, TabPanel } from "./Tabs.js";
20
+ export { Dialog } from "./Dialog.js";
21
+ export { Toaster, toast } from "./Toast.js";
22
+ export { Tooltip } from "./Tooltip.js";
23
+ export { RelativeTime } from "./RelativeTime.js";
@@ -0,0 +1,23 @@
1
+ import { type ChangeEvent } from "react";
2
+ export interface DigitsInputOptions {
3
+ value?: string;
4
+ defaultValue?: string;
5
+ /** حداکثر تعداد رقم */
6
+ maxDigits: number;
7
+ /** متن نمایشی از روی ارقام خام (با ارقام لاتین) */
8
+ format: (raw: string) => string;
9
+ /** پیش‌پردازش ارقام، مثلاً تبدیل +98 به 0 */
10
+ normalize?: (digits: string) => string;
11
+ /** خط ارقام نمایشی */
12
+ digits: "fa" | "latin";
13
+ }
14
+ /**
15
+ * منطق مشترک فیلدهای عددی قالب‌بندی‌شده (کارت، شبا، موبایل، مبلغ):
16
+ * ارقام فارسی و لاتین را می‌پذیرد، بقیه را حذف می‌کند و مکان‌نما را پس از قالب‌بندی سر جایش نگه می‌دارد.
17
+ */
18
+ export declare function useDigitsInput({ value, defaultValue, maxDigits, format, normalize, digits }: DigitsInputOptions): {
19
+ raw: string;
20
+ shown: string;
21
+ inputRef: import("react").RefObject<HTMLInputElement>;
22
+ handleChange: (e: ChangeEvent<HTMLInputElement>) => string;
23
+ };
@@ -0,0 +1,46 @@
1
+ import { useRef, useState } from "react";
2
+ import { onlyDigits } from "@kashi-ui/persian";
3
+ import { useIsomorphicLayoutEffect } from "./utils.js";
4
+ const FA = "۰۱۲۳۴۵۶۷۸۹";
5
+ const fa = (s) => s.replace(/\d/g, (d) => FA[Number(d)]);
6
+ /** موقعیت مکان‌نما پس از n رقم در متن قالب‌بندی‌شده */
7
+ function caretAfter(text, n) {
8
+ if (n === 0)
9
+ return text.search(/[0-9۰-۹]/) === -1 ? text.length : text.search(/[0-9۰-۹]/);
10
+ let seen = 0;
11
+ for (let i = 0; i < text.length; i++)
12
+ if (/[0-9۰-۹]/.test(text[i]) && ++seen === n)
13
+ return i + 1;
14
+ return text.length;
15
+ }
16
+ /**
17
+ * منطق مشترک فیلدهای عددی قالب‌بندی‌شده (کارت، شبا، موبایل، مبلغ):
18
+ * ارقام فارسی و لاتین را می‌پذیرد، بقیه را حذف می‌کند و مکان‌نما را پس از قالب‌بندی سر جایش نگه می‌دارد.
19
+ */
20
+ export function useDigitsInput({ value, defaultValue = "", maxDigits, format, normalize = (d) => d, digits }) {
21
+ const clean = (input) => normalize(onlyDigits(input)).slice(0, maxDigits);
22
+ const [inner, setInner] = useState(() => clean(defaultValue));
23
+ const raw = value !== undefined ? clean(value) : inner;
24
+ const formatted = format(raw);
25
+ const shown = digits === "fa" ? fa(formatted) : formatted;
26
+ const inputRef = useRef(null);
27
+ const caret = useRef(null);
28
+ useIsomorphicLayoutEffect(() => {
29
+ if (caret.current !== null && inputRef.current === document.activeElement) {
30
+ const pos = caretAfter(shown, caret.current);
31
+ inputRef.current?.setSelectionRange(pos, pos);
32
+ caret.current = null;
33
+ }
34
+ });
35
+ /** ارقام خام جدید را برمی‌گرداند؛ حالت داخلی را هم به‌روز می‌کند */
36
+ const handleChange = (e) => {
37
+ const el = e.target;
38
+ const before = el.value.slice(0, el.selectionStart ?? el.value.length);
39
+ const next = clean(el.value);
40
+ caret.current = Math.min(onlyDigits(before).length, next.length);
41
+ if (value === undefined)
42
+ setInner(next);
43
+ return next;
44
+ };
45
+ return { raw, shown, inputRef, handleChange };
46
+ }
package/dist/utils.d.ts CHANGED
@@ -1,5 +1,7 @@
1
- import type { Ref, RefCallback } from "react";
1
+ import { useLayoutEffect, type Ref, type RefCallback } from "react";
2
2
  export { toFaDigits } from "@kashi-ui/persian";
3
3
  /** چند ref را یکی می‌کند */
4
4
  export declare function mergeRefs<T>(...refs: Array<Ref<T> | undefined>): RefCallback<T>;
5
5
  export declare function isDev(): boolean;
6
+ /** useLayoutEffect در مرورگر، useEffect در سرور؛ تا در Next.js و رندر سمت سرور هشدار ندهد */
7
+ export declare const useIsomorphicLayoutEffect: typeof useLayoutEffect;
package/dist/utils.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { useEffect, useLayoutEffect } from "react";
1
2
  export { toFaDigits } from "@kashi-ui/persian";
2
3
  /** چند ref را یکی می‌کند */
3
4
  export function mergeRefs(...refs) {
@@ -13,3 +14,5 @@ export function mergeRefs(...refs) {
13
14
  export function isDev() {
14
15
  return typeof process !== "undefined" && process.env.NODE_ENV !== "production";
15
16
  }
17
+ /** useLayoutEffect در مرورگر، useEffect در سرور؛ تا در Next.js و رندر سمت سرور هشدار ندهد */
18
+ export const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kashi-ui/react",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "کامپوننت‌های React دیزاین‌سیستم کاشی",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "test": "node --test test/*.test.mjs"
23
23
  },
24
24
  "peerDependencies": {
25
- "@kashi-ui/css": "^0.3.0",
25
+ "@kashi-ui/css": "^0.5.0",
26
26
  "react": ">=18"
27
27
  },
28
28
  "devDependencies": {
@@ -59,6 +59,7 @@
59
59
  }
60
60
  },
61
61
  "dependencies": {
62
- "@kashi-ui/persian": "^0.3.0"
62
+ "@kashi-ui/bank-logos": "^0.5.0",
63
+ "@kashi-ui/persian": "^0.5.0"
63
64
  }
64
65
  }