filters

Filters with i18n support

Filters with i18n support

Загрузка превью…

src/filters/c-filters-9.vue

<!--
  Порт c-filters-9.tsx ("Filters with i18n support", registry-reui/bases/
  radix/components/filters/c-filters-9.tsx, MIT).

  `FilterI18nConfig` принимается компонентом `<Filters>` через проп `i18n`
  (packages/ui/src/reui/filters/Filters.vue, см. `mergedI18n` — сливается с
  `DEFAULT_I18N` по ключу, частичный конфиг допустим). Здесь передан полный
  набор строк на пяти языках (en/es/fr/de/ja, как в оригинале), а не только
  различающиеся ключи, чтобы условный `currentI18n` был типобезопасным
  `FilterI18nConfig` без `Partial<...>`.

  `IconPlaceholder` -> инлайновый `<svg>` (lucide-react v0.545.0: user, mail,
  building, circle-check, map-pin, list-filter, chevron-down).
-->
<script setup lang="ts">
import { computed, h, ref } from "vue"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { createFilter, Filters, type Filter, type FilterFieldConfig, type FilterI18nConfig } from "@/components/reui/filters"

function UserIcon() {
  return h(
    "svg",
    {
      xmlns: "http://www.w3.org/2000/svg",
      viewBox: "0 0 24 24",
      fill: "none",
      stroke: "currentColor",
      "stroke-width": "2",
      "stroke-linecap": "round",
      "stroke-linejoin": "round",
      class: "size-3.5",
    },
    [
      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" }),
    ]
  )
}

function MailIcon() {
  return h(
    "svg",
    {
      xmlns: "http://www.w3.org/2000/svg",
      viewBox: "0 0 24 24",
      fill: "none",
      stroke: "currentColor",
      "stroke-width": "2",
      "stroke-linecap": "round",
      "stroke-linejoin": "round",
      class: "size-3.5",
    },
    [
      h("path", { d: "m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7" }),
      h("rect", { x: "2", y: "4", width: "20", height: "16", rx: "2" }),
    ]
  )
}

function BuildingIcon() {
  return h(
    "svg",
    {
      xmlns: "http://www.w3.org/2000/svg",
      viewBox: "0 0 24 24",
      fill: "none",
      stroke: "currentColor",
      "stroke-width": "2",
      "stroke-linecap": "round",
      "stroke-linejoin": "round",
      class: "size-3.5",
    },
    [
      h("path", { d: "M12 10h.01" }),
      h("path", { d: "M12 14h.01" }),
      h("path", { d: "M12 6h.01" }),
      h("path", { d: "M16 10h.01" }),
      h("path", { d: "M16 14h.01" }),
      h("path", { d: "M16 6h.01" }),
      h("path", { d: "M8 10h.01" }),
      h("path", { d: "M8 14h.01" }),
      h("path", { d: "M8 6h.01" }),
      h("path", { d: "M9 22v-3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" }),
      h("rect", { x: "4", y: "2", width: "16", height: "20", rx: "2" }),
    ]
  )
}

function CircleCheckIcon() {
  return h(
    "svg",
    {
      xmlns: "http://www.w3.org/2000/svg",
      viewBox: "0 0 24 24",
      fill: "none",
      stroke: "currentColor",
      "stroke-width": "2",
      "stroke-linecap": "round",
      "stroke-linejoin": "round",
      class: "size-3.5",
    },
    [h("circle", { cx: "12", cy: "12", r: "10" }), h("path", { d: "m9 12 2 2 4-4" })]
  )
}

function MapPinIcon() {
  return h(
    "svg",
    {
      xmlns: "http://www.w3.org/2000/svg",
      viewBox: "0 0 24 24",
      fill: "none",
      stroke: "currentColor",
      "stroke-width": "2",
      "stroke-linecap": "round",
      "stroke-linejoin": "round",
      class: "size-3.5",
    },
    [
      h("path", {
        d: "M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0",
      }),
      h("circle", { cx: "12", cy: "10", r: "3" }),
    ]
  )
}

function ListFilterIcon() {
  return h(
    "svg",
    {
      xmlns: "http://www.w3.org/2000/svg",
      viewBox: "0 0 24 24",
      fill: "none",
      stroke: "currentColor",
      "stroke-width": "2",
      "stroke-linecap": "round",
      "stroke-linejoin": "round",
    },
    [h("path", { d: "M2 5h20" }), h("path", { d: "M6 12h12" }), h("path", { d: "M9 19h6" })]
  )
}

function ChevronDownIcon() {
  return h(
    "svg",
    {
      xmlns: "http://www.w3.org/2000/svg",
      viewBox: "0 0 24 24",
      fill: "none",
      stroke: "currentColor",
      "stroke-width": "2",
      "stroke-linecap": "round",
      "stroke-linejoin": "round",
      class: "size-4",
    },
    [h("path", { d: "m6 9 6 6 6-6" })]
  )
}

// Internationalization configurations
const i18nConfigs: Record<string, FilterI18nConfig> = {
  en: {
    addFilter: "Add filter",
    searchFields: "Search fields...",
    noFieldsFound: "No fields found.",
    noResultsFound: "No results found.",
    select: "Select...",
    true: "True",
    false: "False",
    min: "Min",
    max: "Max",
    to: "to",
    typeAndPressEnter: "Type and press Enter to add tag",
    selected: "selected",
    selectedCount: "selected",
    percent: "%",
    defaultCurrency: "$",
    defaultColor: "#000000",
    addFilterTitle: "Add filter",
    operators: {
      is: "is",
      isNot: "is not",
      isAnyOf: "is any of",
      isNotAnyOf: "is not any of",
      includesAll: "includes all",
      excludesAll: "excludes all",
      before: "before",
      after: "after",
      between: "between",
      notBetween: "not between",
      contains: "contains",
      notContains: "does not contain",
      startsWith: "starts with",
      endsWith: "ends with",
      isExactly: "is exactly",
      equals: "equals",
      notEquals: "not equals",
      greaterThan: "greater than",
      lessThan: "less than",
      overlaps: "overlaps",
      includes: "includes",
      excludes: "excludes",
      includesAllOf: "includes all of",
      includesAnyOf: "includes any of",
      empty: "is empty",
      notEmpty: "is not empty",
    },
    placeholders: {
      enterField: (fieldType: string) => `Enter ${fieldType}...`,
      selectField: "Select...",
      searchField: (fieldName: string) => `Search ${fieldName.toLowerCase()}...`,
      enterKey: "Enter key...",
      enterValue: "Enter value...",
    },
    helpers: {
      formatOperator: (operator: string) => operator.replace(/_/g, " "),
    },
    validation: {
      invalidEmail: "Invalid email format",
      invalidUrl: "Invalid URL format",
      invalidTel: "Invalid phone format",
      invalid: "Invalid input format",
    },
  },
  es: {
    addFilter: "Agregar filtro",
    searchFields: "Buscar campos...",
    noFieldsFound: "No se encontraron campos.",
    noResultsFound: "No se encontraron resultados.",
    select: "Seleccionar...",
    true: "Verdadero",
    false: "Falso",
    min: "Mín",
    max: "Máx",
    to: "a",
    typeAndPressEnter: "Escriba y presione Enter para agregar etiqueta",
    selected: "seleccionado",
    selectedCount: "seleccionados",
    percent: "%",
    defaultCurrency: "€",
    defaultColor: "#000000",
    addFilterTitle: "Agregar filtro",
    operators: {
      is: "es",
      isNot: "no es",
      isAnyOf: "es cualquiera de",
      isNotAnyOf: "no es cualquiera de",
      includesAll: "incluye todos",
      excludesAll: "excluye todos",
      before: "antes de",
      after: "después de",
      between: "entre",
      notBetween: "no entre",
      contains: "contiene",
      notContains: "no contiene",
      startsWith: "comienza con",
      endsWith: "termina con",
      isExactly: "es exactamente",
      equals: "igual a",
      notEquals: "no igual a",
      greaterThan: "mayor que",
      lessThan: "menor que",
      overlaps: "se superpone",
      includes: "incluye",
      excludes: "excluye",
      includesAllOf: "incluye todos de",
      includesAnyOf: "incluye cualquiera de",
      empty: "está vacío",
      notEmpty: "no está vacío",
    },
    placeholders: {
      enterField: (fieldType: string) => `Ingrese ${fieldType}...`,
      selectField: "Seleccionar...",
      searchField: (fieldName: string) => `Buscar ${fieldName.toLowerCase()}...`,
      enterKey: "Ingrese clave...",
      enterValue: "Ingrese valor...",
    },
    helpers: {
      formatOperator: (operator: string) => operator.replace(/_/g, " "),
    },
    validation: {
      invalidEmail: "Formato de email inválido",
      invalidUrl: "Formato de URL inválido",
      invalidTel: "Formato de teléfono inválido",
      invalid: "Formato de entrada inválido",
    },
  },
  fr: {
    addFilter: "Ajouter un filtre",
    searchFields: "Rechercher des champs...",
    noFieldsFound: "Aucun champ trouvé.",
    noResultsFound: "Aucun résultat trouvé.",
    select: "Sélectionner...",
    true: "Vrai",
    false: "Faux",
    min: "Min",
    max: "Max",
    to: "à",
    typeAndPressEnter: "Tapez et appuyez sur Entrée pour ajouter une étiquette",
    selected: "sélectionné",
    selectedCount: "sélectionnés",
    percent: "%",
    defaultCurrency: "€",
    defaultColor: "#000000",
    addFilterTitle: "Ajouter un filtre",
    operators: {
      is: "est",
      isNot: "n'est pas",
      isAnyOf: "est l'un de",
      isNotAnyOf: "n'est pas l'un de",
      includesAll: "inclut tous",
      excludesAll: "exclut tous",
      before: "avant",
      after: "après",
      between: "entre",
      notBetween: "pas entre",
      contains: "contient",
      notContains: "ne contient pas",
      startsWith: "commence par",
      endsWith: "se termine par",
      isExactly: "est exactement",
      equals: "égal à",
      notEquals: "pas égal à",
      greaterThan: "supérieur à",
      lessThan: "inférieur à",
      overlaps: "se chevauche",
      includes: "inclut",
      excludes: "exclut",
      includesAllOf: "inclut tous de",
      includesAnyOf: "inclut l'un de",
      empty: "est vide",
      notEmpty: "n'est pas vide",
    },
    placeholders: {
      enterField: (fieldType: string) => `Entrez ${fieldType}...`,
      selectField: "Sélectionner...",
      searchField: (fieldName: string) => `Rechercher ${fieldName.toLowerCase()}...`,
      enterKey: "Entrez la clé...",
      enterValue: "Entrez la valeur...",
    },
    helpers: {
      formatOperator: (operator: string) => operator.replace(/_/g, " "),
    },
    validation: {
      invalidEmail: "Format d'email invalide",
      invalidUrl: "Format d'URL invalide",
      invalidTel: "Format de téléphone invalide",
      invalid: "Format de saisie invalide",
    },
  },
  de: {
    addFilter: "Filter hinzufügen",
    searchFields: "Felder suchen...",
    noFieldsFound: "Keine Felder gefunden.",
    noResultsFound: "Keine Ergebnisse gefunden.",
    select: "Auswählen...",
    true: "Wahr",
    false: "Falsch",
    min: "Min",
    max: "Max",
    to: "bis",
    typeAndPressEnter: "Tippen und Enter drücken, um Tag hinzuzufügen",
    selected: "ausgewählt",
    selectedCount: "ausgewählt",
    percent: "%",
    defaultCurrency: "€",
    defaultColor: "#000000",
    addFilterTitle: "Filter hinzufügen",
    operators: {
      is: "ist",
      isNot: "ist nicht",
      isAnyOf: "ist eines von",
      isNotAnyOf: "ist nicht eines von",
      includesAll: "enthält alle",
      excludesAll: "schließt alle aus",
      before: "vor",
      after: "nach",
      between: "zwischen",
      notBetween: "nicht zwischen",
      contains: "enthält",
      notContains: "enthält nicht",
      startsWith: "beginnt mit",
      endsWith: "endet mit",
      isExactly: "ist genau",
      equals: "gleich",
      notEquals: "nicht gleich",
      greaterThan: "größer als",
      lessThan: "kleiner als",
      overlaps: "überschneidet sich",
      includes: "enthält",
      excludes: "schließt aus",
      includesAllOf: "enthält alle von",
      includesAnyOf: "enthält eines von",
      empty: "ist leer",
      notEmpty: "ist nicht leer",
    },
    placeholders: {
      enterField: (fieldType: string) => `${fieldType} eingeben...`,
      selectField: "Auswählen...",
      searchField: (fieldName: string) => `${fieldName.toLowerCase()} suchen...`,
      enterKey: "Schlüssel eingeben...",
      enterValue: "Wert eingeben...",
    },
    helpers: {
      formatOperator: (operator: string) => operator.replace(/_/g, " "),
    },
    validation: {
      invalidEmail: "Ungültiges E-Mail-Format",
      invalidUrl: "Ungültiges URL-Format",
      invalidTel: "Ungültiges Telefonformat",
      invalid: "Ungültiges Format",
    },
  },
  ja: {
    addFilter: "フィルターを追加",
    searchFields: "フィールドを検索...",
    noFieldsFound: "フィールドが見つかりません。",
    noResultsFound: "結果が見つかりません。",
    select: "選択...",
    true: "真",
    false: "偽",
    min: "最小",
    max: "最大",
    to: "から",
    typeAndPressEnter: "入力してEnterキーを押してタグを追加",
    selected: "選択済み",
    selectedCount: "選択済み",
    percent: "%",
    defaultCurrency: "¥",
    defaultColor: "#000000",
    addFilterTitle: "フィルターを追加",
    operators: {
      is: "は",
      isNot: "ではない",
      isAnyOf: "のいずれか",
      isNotAnyOf: "のいずれでもない",
      includesAll: "すべて含む",
      excludesAll: "すべて除外",
      before: "より前",
      after: "より後",
      between: "の間",
      notBetween: "の間ではない",
      contains: "含む",
      notContains: "含まない",
      startsWith: "で始まる",
      endsWith: "で終わる",
      isExactly: "正確に",
      equals: "等しい",
      notEquals: "等しくない",
      greaterThan: "より大きい",
      lessThan: "より小さい",
      overlaps: "重複する",
      includes: "含む",
      excludes: "除外",
      includesAllOf: "すべて含む",
      includesAnyOf: "いずれか含む",
      empty: "空",
      notEmpty: "空でない",
    },
    placeholders: {
      enterField: (fieldType: string) => `${fieldType}を入力...`,
      selectField: "選択...",
      searchField: (fieldName: string) => `${fieldName.toLowerCase()}を検索...`,
      enterKey: "キーを入力...",
      enterValue: "値を入力...",
    },
    helpers: {
      formatOperator: (operator: string) => operator.replace(/_/g, " "),
    },
    validation: {
      invalidEmail: "無効なメール形式",
      invalidUrl: "無効なURL形式",
      invalidTel: "無効な電話番号形式",
      invalid: "無効な形式",
    },
  },
}

// Language options for the selector
const languageOptions = [
  { value: "en", label: "English", flag: "us" },
  { value: "es", label: "Español", flag: "es" },
  { value: "fr", label: "Français", flag: "fr" },
  { value: "de", label: "Deutsch", flag: "de" },
  { value: "ja", label: "日本語", flag: "jp" },
]

const currentLanguage = ref<string>("es")

const currentI18n = computed<FilterI18nConfig>(() => i18nConfigs[currentLanguage.value] ?? i18nConfigs.en!)

const currentLangOption = computed(() => languageOptions.find((l) => l.value === currentLanguage.value))

const fieldLabels: Record<string, Record<string, string>> = {
  en: {
    name: "Name",
    email: "Email",
    company: "Company",
    role: "Role",
    status: "Status",
    location: "Location",
    joined: "Joined Date",
    balance: "Balance",
    rating: "Rating",
  },
  es: {
    name: "Nombre",
    email: "Correo electrónico",
    company: "Empresa",
    role: "Rol",
    status: "Estado",
    location: "Ubicación",
    joined: "Fecha de ingreso",
    balance: "Saldo",
    rating: "Calificación",
  },
  fr: {
    name: "Nom",
    email: "E-mail",
    company: "Entreprise",
    role: "Rôle",
    status: "Statut",
    location: "Localisation",
    joined: "Date d'adhésion",
    balance: "Solde",
    rating: "Note",
  },
  de: {
    name: "Name",
    email: "E-Mail",
    company: "Unternehmen",
    role: "Rolle",
    status: "Status",
    location: "Standort",
    joined: "Beitrittsdatum",
    balance: "Guthaben",
    rating: "Bewertung",
  },
  ja: {
    name: "名前",
    email: "メール",
    company: "会社",
    role: "役割",
    status: "ステータス",
    location: "場所",
    joined: "参加日",
    balance: "残高",
    rating: "評価",
  },
}

function localizedPlaceholder(kind: "names" | "locations"): string {
  const lang = currentLanguage.value
  if (kind === "names") {
    if (lang === "en") return "Search names..."
    if (lang === "es") return "Buscar nombres..."
    if (lang === "fr") return "Rechercher des noms..."
    if (lang === "de") return "Namen suchen..."
    return "名前を検索..."
  }
  if (lang === "en") return "Search locations..."
  if (lang === "es") return "Buscar ubicaciones..."
  if (lang === "fr") return "Rechercher des lieux..."
  if (lang === "de") return "Standorte suchen..."
  return "場所を検索..."
}

function localizedStatus(value: "active" | "inactive"): string {
  const lang = currentLanguage.value
  if (value === "active") {
    if (lang === "en") return "Active"
    if (lang === "es") return "Activo"
    if (lang === "fr") return "Actif"
    if (lang === "de") return "Aktiv"
    return "アクティブ"
  }
  if (lang === "en") return "Inactive"
  if (lang === "es") return "Inactivo"
  if (lang === "fr") return "Inactif"
  if (lang === "de") return "Inaktiv"
  return "非アクティブ"
}

const fields = computed<FilterFieldConfig<string>[]>(() => {
  const labels = fieldLabels[currentLanguage.value] ?? fieldLabels.en!

  return [
    {
      key: "name",
      label: labels.name,
      icon: UserIcon,
      type: "text",
      class: "w-40",
      placeholder: localizedPlaceholder("names"),
    },
    {
      key: "email",
      label: labels.email,
      icon: MailIcon,
      type: "text",
      class: "w-48",
      placeholder: "user@example.com",
    },
    {
      key: "company",
      label: labels.company,
      icon: BuildingIcon,
      type: "select",
      searchable: true,
      class: "w-[180px]",
      options: [
        { value: "TechCorp", label: "TechCorp" },
        { value: "StartupCo", label: "StartupCo" },
        { value: "BigCorp", label: "BigCorp" },
        { value: "InnovateTech", label: "InnovateTech" },
        { value: "GlobalNet", label: "GlobalNet" },
      ],
    },
    {
      key: "role",
      label: labels.role,
      icon: UserIcon,
      type: "select",
      searchable: true,
      class: "w-[160px]",
      options: [
        { value: "Developer", label: "Developer" },
        { value: "Designer", label: "Designer" },
        { value: "Manager", label: "Manager" },
        { value: "Product Manager", label: "Product Manager" },
        { value: "Sales Rep", label: "Sales Rep" },
      ],
    },
    {
      key: "status",
      label: labels.status,
      icon: CircleCheckIcon,
      type: "select",
      searchable: false,
      class: "w-[140px]",
      options: [
        { value: "active", label: localizedStatus("active") },
        { value: "inactive", label: localizedStatus("inactive") },
      ],
    },
    {
      key: "location",
      label: labels.location,
      icon: MapPinIcon,
      type: "text",
      class: "w-40",
      placeholder: localizedPlaceholder("locations"),
    },
  ]
})

const filters = ref<Filter<string>[]>([createFilter("status", "is", ["active"])])

function handleFiltersChange(next: Filter<string>[]) {
  filters.value = next
}
</script>

<template>
  <div class="flex w-full grow items-start justify-between space-y-6 self-start">
    <Filters :filters="filters" :fields="fields" size="sm" :i18n="currentI18n" @change="handleFiltersChange">
      <template #trigger>
        <Button variant="outline" size="icon-sm">
          <ListFilterIcon />
        </Button>
      </template>
    </Filters>

    <div class="flex items-center gap-2">
      <DropdownMenu>
        <DropdownMenuTrigger as-child>
          <Button variant="outline" size="sm" class="flex items-center gap-2">
            <img
              v-if="currentLangOption"
              :src="`https://flagcdn.com/${currentLangOption.flag.toLowerCase()}.svg`"
              :alt="currentLangOption.flag"
              class="size-4 rounded-full object-cover"
            />
            <span>{{ currentLangOption?.label }}</span>
            <ChevronDownIcon />
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="start">
          <DropdownMenuItem
            v-for="lang in languageOptions"
            :key="lang.value"
            class="flex items-center gap-2"
            @click="currentLanguage = lang.value"
          >
            <img
              :src="`https://flagcdn.com/${lang.flag.toLowerCase()}.svg`"
              :alt="lang.flag"
              class="size-4 rounded-full object-cover"
            />
            <span>{{ lang.label }}</span>
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    </div>
  </div>
</template>

Установка

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

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

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