filters
Filters with input validation
Filters with input validation
Загрузка превью…
src/filters/c-filters-2.vue
<!--
Порт c-filters-2 ("Filters with input validation") —
.agent-tmp/reui-upstream/registry-reui/bases/radix/components/filters/c-filters-2.tsx
Отличия от апстрима (см. docs/PORTING.md §5, §12):
- `IconPlaceholder` заменён инлайновым `<svg>` (lucide-react v0.545.0 paths),
тем же приёмом, что и в самом примитиве.
- Апстрим строит валидаторы оборачивая схемы `zod` (`zodValidator(schema)`).
`zod` не входит в зависимости `@revueui/blocks` (это единственный паттерн
во всей категории `filters`, где он нужен), а `field.validation` здесь не
покрыт визуальным дифом (срабатывает только на `blur` внутри раскрытого
состояния поля, которое стенд не проверяет, см. docs/PORTING.md §5 —
"filters"). Поэтому валидаторы переписаны как обычные функции с теми же
правилами и тем же порядком сообщений об ошибке, без добавления зависимости
ради непроверяемой диффом логики. `customUrl` в апстриме и так не
использует zod — перенесён буквально.
- Состояние демо зафиксировано (docs/PORTING.md, задача): `useState` убран,
`filters` — константа, `onChange` — no-op. `createFilter()` не вызывается
(использует `Date.now()`/`Math.random()` внутри) — id захардкожен.
-->
<script setup lang="ts">
import { h, type VNodeChild } from "vue"
import { Filters, type Filter, type FilterFieldConfig } from "@/components/reui/filters"
import { Button } from "@/components/ui/button"
function svgIcon(paths: () => VNodeChild[]) {
return (cls?: string) => () =>
h(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
class: cls,
},
paths()
)
}
const AtSignIcon = svgIcon(() => [
h("circle", { cx: "12", cy: "12", r: "4" }),
h("path", { d: "M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" }),
])
const GlobeIcon = svgIcon(() => [
h("circle", { cx: "12", cy: "12", r: "10" }),
h("path", { d: "M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" }),
h("path", { d: "M2 12h20" }),
])
const PhoneIcon = svgIcon(() => [
h("path", {
d: "M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384",
}),
])
const UserIcon = svgIcon(() => [
h("path", { d: "M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" }),
h("circle", { cx: "12", cy: "7", r: "4" }),
])
const CreditCardIcon = svgIcon(() => [
h("rect", { width: "20", height: "14", x: "2", y: "5", rx: "2" }),
h("line", { x1: "2", x2: "22", y1: "10", y2: "10" }),
])
const LinkIcon = svgIcon(() => [
h("path", { d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" }),
h("path", { d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" }),
])
const ListFilterIcon = svgIcon(() => [
h("path", { d: "M2 5h20" }),
h("path", { d: "M6 12h12" }),
h("path", { d: "M9 19h6" }),
])
// ---------------------------------------------------------------------------
// Validators — same rules/messages/order as the upstream zod schemas, see
// file header for why this isn't `zod` itself.
// ---------------------------------------------------------------------------
type ValidationResult = { valid: boolean; message?: string }
function validateEmail(value: unknown): ValidationResult {
const v = String(value)
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailPattern.test(v)) {
return { valid: false, message: "Please enter a valid email address" }
}
return { valid: true }
}
function validateUrl(value: unknown): ValidationResult {
try {
new URL(String(value))
return { valid: true }
} catch {
return { valid: false, message: "Please enter a valid URL (e.g., https://example.com)" }
}
}
function validatePhone(value: unknown): ValidationResult {
const v = String(value)
if (!/^\+?[1-9]\d{1,14}$/.test(v)) {
return { valid: false, message: "Please enter a valid phone number" }
}
return { valid: true }
}
function validateUsername(value: unknown): ValidationResult {
const v = String(value)
if (v.length < 3) {
return { valid: false, message: "Username must be at least 3 characters" }
}
if (v.length > 20) {
return { valid: false, message: "Username must be at most 20 characters" }
}
if (!/^[a-zA-Z0-9_]+$/.test(v)) {
return { valid: false, message: "Username can only contain letters, numbers, and underscores" }
}
return { valid: true }
}
function validateCreditCard(value: unknown): ValidationResult {
const v = String(value)
if (!/^\d{13,19}$/.test(v)) {
return { valid: false, message: "Please enter a valid credit card number (13-19 digits)" }
}
return { valid: true }
}
function validateCustomUrl(value: unknown): ValidationResult {
const urlPattern = /^https?:\/\/.+\..+/
if (!urlPattern.test(value as string)) {
return { valid: false, message: "URL must start with http:// or https://" }
}
return { valid: true }
}
const fields: FilterFieldConfig<string>[] = [
{
key: "email",
label: "Email",
icon: AtSignIcon("size-3.5"),
type: "text",
placeholder: "user@example.com",
validation: validateEmail,
},
{
key: "website",
label: "Website",
icon: GlobeIcon("size-3.5"),
type: "text",
placeholder: "https://example.com",
validation: validateUrl,
},
{
key: "phone",
label: "Phone",
icon: PhoneIcon("size-3.5"),
type: "text",
placeholder: "+1234567890",
validation: validatePhone,
},
{
key: "username",
label: "Username",
icon: UserIcon("size-3.5"),
type: "text",
class: "w-44",
placeholder: "john_doe",
validation: validateUsername,
},
{
key: "cardNumber",
label: "Card Number",
icon: CreditCardIcon("size-3.5"),
type: "text",
placeholder: "4111111111111111",
validation: validateCreditCard,
},
{
key: "customUrl",
label: "Custom URL",
icon: LinkIcon("size-3.5"),
type: "text",
placeholder: "https://...",
validation: validateCustomUrl,
},
]
// Стенд: состояние зафиксировано (docs/PORTING.md §5, задача).
const filters: Filter<string>[] = [{ id: "f1", field: "email", operator: "contains", values: [""] }]
function noop() {}
</script>
<template>
<div class="flex grow content-start items-start self-start">
<Filters :filters="filters" :fields="fields" @change="noop">
<template #trigger>
<Button variant="outline" size="icon">
<component :is="ListFilterIcon()" />
</Button>
</template>
</Filters>
</div>
</template>