feat: add support tickets and imrpove web app loading
This commit is contained in:
@@ -68,7 +68,7 @@
|
||||
const accent = readCssColor("--accent", "#00fe7a");
|
||||
const lineStroke = readCssColor(
|
||||
"--admin-chart-stroke",
|
||||
readCssColor("--admin-text", "#e8f0ec"),
|
||||
readCssColor("--admin-text", "#e8f0ec")
|
||||
);
|
||||
const lineFill = readCssColor("--admin-chart-fill", "rgba(120, 140, 132, 0.14)");
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script>
|
||||
import { Lock, Send } from "$components/ui/icons.js";
|
||||
import { Spinner, Textarea } from "$components/ui/index.js";
|
||||
import { Switch } from "$components/ui/primitives.js";
|
||||
import { AdminButton } from "$components/patterns/admin/index.js";
|
||||
|
||||
export let value = "";
|
||||
export let internal = false;
|
||||
export let sending = false;
|
||||
export let at = (key) => key;
|
||||
export let onToggleInternal = () => {};
|
||||
export let onSend = () => {};
|
||||
|
||||
function submit() {
|
||||
if (sending || !value.trim()) return;
|
||||
onSend(value.trim());
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="support-admin-composer">
|
||||
<Textarea
|
||||
bind:value
|
||||
rows={4}
|
||||
placeholder={at("support_reply_placeholder", {}, "Ответ")}
|
||||
ariaLabel={at("support_reply_placeholder", {}, "Ответ")}
|
||||
class="support-admin-composer-textarea"
|
||||
/>
|
||||
|
||||
<div class="support-admin-composer-row">
|
||||
<div class="support-admin-note-toggle">
|
||||
<Switch.Root
|
||||
id="support-internal-note"
|
||||
checked={internal}
|
||||
onCheckedChange={onToggleInternal}
|
||||
class="admin-switch-root"
|
||||
>
|
||||
<Switch.Thumb class="admin-switch-thumb" />
|
||||
</Switch.Root>
|
||||
<label for="support-internal-note">
|
||||
<Lock size={14} />
|
||||
<span>{at("support_internal_note", {}, "Внутренняя заметка")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<AdminButton variant="primary" disabled={sending || !value.trim()} onclick={submit}>
|
||||
{#if sending}<Spinner size="sm" />{:else}<Send size={14} />{/if}
|
||||
{at("send", {}, "Отправить")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script>
|
||||
import { AdminBadge } from "$components/patterns/admin/index.js";
|
||||
import { MessageSquare } from "$components/ui/icons.js";
|
||||
|
||||
export let ticket;
|
||||
export let active = false;
|
||||
export let at = (key) => key;
|
||||
export let onOpen = () => {};
|
||||
|
||||
$: user = ticket?.user || {};
|
||||
$: timeLabel = formatTime(ticket?.last_message_at || ticket?.updated_at || ticket?.created_at);
|
||||
$: userLabel = user.username ? `@${user.username}` : user.email || user.user_id || "-";
|
||||
$: avatarUrl = user?.avatar_url || user?.photo_url || "";
|
||||
$: avatarInitials = computeInitials(user);
|
||||
$: categoryLabel = at(`support_category_${ticket?.category}`, {}, ticket?.category || "-");
|
||||
$: statusVariant =
|
||||
ticket?.status === "closed" || ticket?.status === "resolved" ? "muted" : "success";
|
||||
$: priorityVariant =
|
||||
ticket?.priority === "urgent" ? "danger" : ticket?.priority === "high" ? "warning" : "muted";
|
||||
|
||||
function computeInitials(u) {
|
||||
const source =
|
||||
[u?.first_name, u?.last_name].filter(Boolean).join(" ").trim() ||
|
||||
u?.username ||
|
||||
u?.email ||
|
||||
String(u?.user_id || "");
|
||||
const clean = String(source).replace(/^@/, "").trim();
|
||||
const parts = clean.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
return (clean.slice(0, 2) || "U").toUpperCase();
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class:active
|
||||
class="support-inbox-row"
|
||||
type="button"
|
||||
data-status={ticket?.status}
|
||||
data-priority={ticket?.priority}
|
||||
on:click={() => onOpen(ticket)}
|
||||
>
|
||||
<span class="support-inbox-row-avatar" aria-hidden="true">
|
||||
{#if avatarUrl}
|
||||
<img src={avatarUrl} alt="" loading="lazy" referrerpolicy="no-referrer" />
|
||||
{:else}
|
||||
{avatarInitials}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span class="support-inbox-row-main">
|
||||
<span class="support-inbox-row-title">
|
||||
<MessageSquare size={15} />
|
||||
<strong>{ticket.subject}</strong>
|
||||
</span>
|
||||
<small>#{ticket.ticket_id} / {userLabel} / {categoryLabel}</small>
|
||||
</span>
|
||||
|
||||
<span class="support-row-badges">
|
||||
<AdminBadge variant={statusVariant}
|
||||
>{at(`support_status_${ticket.status}`, {}, ticket.status)}</AdminBadge
|
||||
>
|
||||
<AdminBadge variant={priorityVariant}>
|
||||
{at(`support_priority_${ticket.priority}`, {}, ticket.priority)}
|
||||
</AdminBadge>
|
||||
{#if ticket.unread_admin_count}
|
||||
<b>
|
||||
<span class="numeric-badge-value">{ticket.unread_admin_count}</span>
|
||||
</b>
|
||||
{/if}
|
||||
{#if timeLabel}<small>{timeLabel}</small>{/if}
|
||||
</span>
|
||||
</button>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script>
|
||||
import { AdminBadge, AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
|
||||
import { CheckCheck } from "$components/ui/icons.js";
|
||||
|
||||
export let ticket;
|
||||
export let at = (key) => key;
|
||||
export let onPatch = () => {};
|
||||
export let onClose = () => {};
|
||||
|
||||
$: statusOptions = ["open", "awaiting_user", "awaiting_admin", "resolved", "closed"].map(
|
||||
(item) => ({
|
||||
value: item,
|
||||
label: at(`support_status_${item}`, {}, item),
|
||||
})
|
||||
);
|
||||
$: priorityOptions = ["low", "normal", "high", "urgent"].map((item) => ({
|
||||
value: item,
|
||||
label: at(`support_priority_${item}`, {}, item),
|
||||
}));
|
||||
$: categoryOptions = ["billing", "technical", "account", "other"].map((item) => ({
|
||||
value: item,
|
||||
label: at(`support_category_${item}`, {}, item),
|
||||
}));
|
||||
$: statusVariant =
|
||||
ticket?.status === "closed" || ticket?.status === "resolved" ? "muted" : "success";
|
||||
$: priorityVariant =
|
||||
ticket?.priority === "urgent" ? "danger" : ticket?.priority === "high" ? "warning" : "muted";
|
||||
|
||||
function patch(key, value) {
|
||||
if (!ticket || ticket[key] === value) return;
|
||||
onPatch({ [key]: value });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if ticket}
|
||||
<div class="support-ticket-header">
|
||||
<div class="support-ticket-statusbar">
|
||||
<AdminBadge variant={statusVariant}>
|
||||
{at(`support_status_${ticket.status}`, {}, ticket.status)}
|
||||
</AdminBadge>
|
||||
<AdminBadge variant={priorityVariant}>
|
||||
{at(`support_priority_${ticket.priority}`, {}, ticket.priority)}
|
||||
</AdminBadge>
|
||||
</div>
|
||||
|
||||
<div class="support-ticket-actions">
|
||||
<AdminButton
|
||||
class="support-ticket-close"
|
||||
variant="dangerSoft"
|
||||
onclick={onClose}
|
||||
disabled={ticket.status === "closed"}
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
{at("support_close_ticket", {}, "Закрыть тикет")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
|
||||
<div class="support-ticket-controls">
|
||||
<AdminSelect
|
||||
value={ticket.status}
|
||||
items={statusOptions}
|
||||
ariaLabel={at("support_status", {}, "Статус")}
|
||||
onValueChange={(value) => patch("status", value)}
|
||||
/>
|
||||
<AdminSelect
|
||||
value={ticket.priority}
|
||||
items={priorityOptions}
|
||||
ariaLabel={at("support_priority", {}, "Приоритет")}
|
||||
onValueChange={(value) => patch("priority", value)}
|
||||
/>
|
||||
<AdminSelect
|
||||
value={ticket.category}
|
||||
items={categoryOptions}
|
||||
ariaLabel={at("support_category", {}, "Категория")}
|
||||
onValueChange={(value) => patch("category", value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,75 @@
|
||||
<script>
|
||||
import { AdminBadge, AdminButton } from "$components/patterns/admin/index.js";
|
||||
import { User } from "$components/ui/icons.js";
|
||||
|
||||
export let ticket;
|
||||
export let snapshot = {};
|
||||
export let at = (key) => key;
|
||||
|
||||
$: user = ticket?.user || {};
|
||||
$: displayName = snapshot?.name || user.username || user.email || user.user_id || "-";
|
||||
$: avatarUrl = user?.avatar_url || user?.photo_url || "";
|
||||
$: avatarInitials = computeInitials(user, displayName);
|
||||
$: canOpenUser = user.user_id !== undefined && user.user_id !== null && user.user_id !== "";
|
||||
$: identityMeta =
|
||||
[user.email, canOpenUser ? `ID ${user.user_id}` : ""].filter(Boolean).join(" / ") || "-";
|
||||
$: contextItems = [
|
||||
{ label: at("support_tariff", {}, "Тариф"), value: snapshot?.tariff || "-" },
|
||||
{ label: at("support_status", {}, "Статус"), value: snapshot?.panel_status || "-" },
|
||||
{ label: at("support_remaining", {}, "Осталось"), value: snapshot?.remaining || "-" },
|
||||
];
|
||||
|
||||
function computeInitials(u, fallback) {
|
||||
const source =
|
||||
[u?.first_name, u?.last_name].filter(Boolean).join(" ").trim() ||
|
||||
u?.username ||
|
||||
u?.email ||
|
||||
String(fallback || "");
|
||||
const clean = String(source).replace(/^@/, "").trim();
|
||||
const parts = clean.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
return (clean.slice(0, 2) || "U").toUpperCase();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="support-user-context" aria-label={at("support_user_context", {}, "User")}>
|
||||
<div class="support-user-context-head">
|
||||
<span class="support-user-context-avatar" aria-hidden="true">
|
||||
{#if avatarUrl}
|
||||
<img src={avatarUrl} alt="" loading="lazy" referrerpolicy="no-referrer" />
|
||||
{:else}
|
||||
{avatarInitials}
|
||||
{/if}
|
||||
</span>
|
||||
<div class="support-user-context-identity">
|
||||
<strong>{displayName}</strong>
|
||||
<small>{identityMeta}</small>
|
||||
{#if user.is_banned}
|
||||
<AdminBadge variant="danger">{at("status_banned", {}, "Бан")}</AdminBadge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="support-user-context-metrics">
|
||||
{#each contextItems as item (item.label)}
|
||||
<span>
|
||||
<small>{item.label}</small>
|
||||
<strong>{item.value}</strong>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="support-user-context-actions">
|
||||
<AdminButton
|
||||
class="support-user-card-btn"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={!canOpenUser}
|
||||
onclick={() => (window.location.href = `/admin/users/${user.user_id}`)}
|
||||
aria-label={at("support_open_user", {}, "Карточка")}
|
||||
title={at("support_open_user", {}, "Карточка")}
|
||||
>
|
||||
<User size={14} />
|
||||
</AdminButton>
|
||||
</div>
|
||||
</section>
|
||||
@@ -12,3 +12,7 @@ export { default as AdminSectionHeader } from "./AdminSectionHeader.svelte";
|
||||
export { default as AdminTable } from "./AdminTable.svelte";
|
||||
export { default as AdminTableSkeleton } from "./AdminTableSkeleton.svelte";
|
||||
export { default as AdminTrafficCard } from "./AdminTrafficCard.svelte";
|
||||
export { default as SupportComposer } from "./SupportComposer.svelte";
|
||||
export { default as SupportInboxRow } from "./SupportInboxRow.svelte";
|
||||
export { default as SupportTicketHeader } from "./SupportTicketHeader.svelte";
|
||||
export { default as SupportUserContextPanel } from "./SupportUserContextPanel.svelte";
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script>
|
||||
import { AttentionDot, Badge } from "$components/ui/index.js";
|
||||
import { MessageSquare } from "$components/ui/icons.js";
|
||||
|
||||
export let ticket;
|
||||
export let t = (key) => key;
|
||||
export let onOpen = () => {};
|
||||
|
||||
$: unread = Number(ticket?.unread_user_count || 0);
|
||||
$: timeLabel = formatTime(ticket?.last_message_at || ticket?.updated_at || ticket?.created_at);
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="ticket-card"
|
||||
type="button"
|
||||
data-status={ticket?.status}
|
||||
data-priority={ticket?.priority}
|
||||
on:click={() => onOpen(ticket)}
|
||||
>
|
||||
<span class="ticket-card-main">
|
||||
<span class="ticket-card-title">
|
||||
<MessageSquare size={16} />
|
||||
<strong>{ticket.subject}</strong>
|
||||
</span>
|
||||
<span class="ticket-card-meta">
|
||||
<span>{t("wa_support_ticket_number", { id: ticket.ticket_id })}</span>
|
||||
{#if timeLabel}<span>{timeLabel}</span>{/if}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="ticket-card-side">
|
||||
<span class="ticket-card-badges">
|
||||
<Badge variant="outline" class={`ticket-status-badge ticket-status-badge--${ticket.status}`}>
|
||||
{t(`wa_support_status_${ticket.status}`)}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="muted"
|
||||
class={`ticket-priority-badge ticket-priority-badge--${ticket.priority}`}
|
||||
>
|
||||
{t(`wa_support_priority_${ticket.priority}`)}
|
||||
</Badge>
|
||||
</span>
|
||||
{#if unread}
|
||||
<AttentionDot position="inline" class="ticket-card-unread-dot" />
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script>
|
||||
import { Send } from "$components/ui/icons.js";
|
||||
import { Button, Spinner, Textarea } from "$components/ui/index.js";
|
||||
|
||||
export let value = "";
|
||||
export let maxLength = 4000;
|
||||
export let disabled = false;
|
||||
export let sending = false;
|
||||
export let placeholder = "";
|
||||
export let sendLabel = "";
|
||||
export let onSend = () => {};
|
||||
|
||||
function submit() {
|
||||
if (disabled || sending || !value.trim()) return;
|
||||
onSend(value.trim());
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ticket-composer">
|
||||
<Textarea
|
||||
bind:value
|
||||
rows={3}
|
||||
maxlength={maxLength}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
ariaLabel={placeholder}
|
||||
class="ticket-composer-textarea"
|
||||
/>
|
||||
<div class="ticket-composer-row">
|
||||
<small>{value.length}/{maxLength}</small>
|
||||
<Button
|
||||
type="button"
|
||||
class="ticket-composer-send"
|
||||
disabled={disabled || sending || !value.trim()}
|
||||
onclick={submit}
|
||||
>
|
||||
{#if sending}<Spinner size="sm" />{:else}<Send size={16} />{/if}
|
||||
<span>{sendLabel}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script>
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
import { LifeBuoy, Lock, MessageSquare, UserRound } from "$components/ui/icons.js";
|
||||
|
||||
export let role = "user";
|
||||
export let body = "";
|
||||
export let createdAt = "";
|
||||
export let isInternalNote = false;
|
||||
export let perspective = "user";
|
||||
export let userAvatarUrl = "";
|
||||
export let userInitials = "";
|
||||
export let authorName = "";
|
||||
export let supportBrand = {};
|
||||
export let t = (key, _params = {}, fallback = "") => fallback || key;
|
||||
|
||||
$: messageRole = role || "system";
|
||||
$: serviceMessage = isInternalNote || messageRole === "system";
|
||||
$: outgoing =
|
||||
(perspective === "admin" && (messageRole === "admin" || serviceMessage)) ||
|
||||
(!serviceMessage && perspective !== "admin" && messageRole === "user");
|
||||
$: roleLabel = isInternalNote
|
||||
? [authorName, t("wa_support_internal_note", {}, "Внутренняя заметка")]
|
||||
.filter(Boolean)
|
||||
.join(" / ")
|
||||
: authorName || t(`wa_support_role_${messageRole}`, {}, messageRole);
|
||||
$: timeLabel = formatTime(createdAt);
|
||||
$: showSupportAvatar = !isInternalNote && messageRole === "admin";
|
||||
$: showUserAvatar = !isInternalNote && messageRole === "user";
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<article
|
||||
class={`ticket-message-row ticket-message-row--${messageRole}`.trim()}
|
||||
class:ticket-message-row--outgoing={outgoing}
|
||||
class:ticket-message-row--incoming={!outgoing}
|
||||
class:ticket-message-row--internal={isInternalNote}
|
||||
>
|
||||
<span class="ticket-message-avatar" aria-hidden="true">
|
||||
{#if isInternalNote}
|
||||
<Lock size={15} />
|
||||
{:else if showSupportAvatar}
|
||||
<BrandMark brand={supportBrand} size="sm" fallbackEmoji={true} />
|
||||
{:else if showUserAvatar && userAvatarUrl}
|
||||
<img src={userAvatarUrl} alt="" loading="lazy" referrerpolicy="no-referrer" />
|
||||
{:else if showUserAvatar && userInitials}
|
||||
<strong>{userInitials}</strong>
|
||||
{:else if messageRole === "admin"}
|
||||
<LifeBuoy size={15} />
|
||||
{:else if messageRole === "user"}
|
||||
<UserRound size={15} />
|
||||
{:else}
|
||||
<MessageSquare size={15} />
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<div class="ticket-message-content">
|
||||
<div class="ticket-message-meta">
|
||||
<span class="ticket-message-author">{roleLabel}</span>
|
||||
{#if timeLabel}
|
||||
<time datetime={createdAt}>{timeLabel}</time>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="ticket-message-bubble">
|
||||
<p>{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -4,3 +4,6 @@ export { default as LinearProgress } from "./LinearProgress.svelte";
|
||||
export { default as LanguageSelect } from "./LanguageSelect.svelte";
|
||||
export { default as PaymentMethodGrid } from "./PaymentMethodGrid.svelte";
|
||||
export { default as StatusMessage } from "./StatusMessage.svelte";
|
||||
export { default as TicketCard } from "./TicketCard.svelte";
|
||||
export { default as TicketComposer } from "./TicketComposer.svelte";
|
||||
export { default as TicketMessageBubble } from "./TicketMessageBubble.svelte";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
export let position = "absolute";
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<span
|
||||
data-slot="attention-dot"
|
||||
aria-hidden="true"
|
||||
class={cn("attention-dot", position === "inline" && "attention-dot-inline", className)}
|
||||
{...$$restProps}
|
||||
></span>
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
Bitcoin,
|
||||
CalendarDays,
|
||||
Check,
|
||||
CheckCheck,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
@@ -28,12 +29,15 @@ export {
|
||||
Info,
|
||||
Key,
|
||||
LayoutDashboard,
|
||||
LifeBuoy,
|
||||
Lock,
|
||||
LockKeyhole,
|
||||
Mail,
|
||||
Map,
|
||||
Megaphone,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
MessageSquarePlus,
|
||||
MousePointerClick,
|
||||
Paintbrush,
|
||||
Plus,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as AttentionDot } from "./attention-dot.svelte";
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { default as Button } from "./button.svelte";
|
||||
export { default as Dialog } from "./dialog.svelte";
|
||||
@@ -5,6 +6,8 @@ export { default as Input } from "./input.svelte";
|
||||
export { default as LegacyCard } from "./card.svelte";
|
||||
export { default as Skeleton } from "./skeleton.svelte";
|
||||
export { default as Spinner } from "./spinner.svelte";
|
||||
export { default as ScrollArea } from "./scroll-area.svelte";
|
||||
export { default as Textarea } from "./textarea.svelte";
|
||||
export * as Icons from "./icons.js";
|
||||
export * as Card from "./card/index.js";
|
||||
export { Accordion, Label, Select, Separator, Switch, Tabs, Tooltip } from "./primitives.js";
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script>
|
||||
export let maxHeight = "100%";
|
||||
export let element = null;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
class={`scroll-area ${className}`.trim()}
|
||||
style={`max-height:${maxHeight};`}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script>
|
||||
export let value = "";
|
||||
export let rows = 3;
|
||||
export let disabled = false;
|
||||
export let placeholder = "";
|
||||
export let maxlength = undefined;
|
||||
export let ariaLabel = "";
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<textarea
|
||||
class={`textarea ${className}`.trim()}
|
||||
bind:value
|
||||
{rows}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
{maxlength}
|
||||
aria-label={ariaLabel || placeholder}
|
||||
on:input
|
||||
on:keydown
|
||||
{...$$restProps}
|
||||
></textarea>
|
||||
Reference in New Issue
Block a user