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>

Установка

npx shadcn-vue@latest add https://revueui.rootapi.dev/r/c-filters-2.json

Зависимости реестра

Источник: порт из ReUI (Keenthemes, MIT)