From 26136e70f5f92e119f99a88869f0bd9f0e580a5a Mon Sep 17 00:00:00 2001 From: Maxime Princelle Date: Mon, 10 Nov 2025 16:37:18 +0100 Subject: [PATCH 1/3] feat: add Filters component --- docs/content/docs/2.components/filters.md | 1061 +++++++++++++++++ .../nuxt/app/composables/useNavigation.ts | 1 + .../nuxt/app/pages/components/filters.vue | 707 +++++++++++ src/runtime/components/Filters.vue | 267 +++++ .../components/filters/AddFilterPopover.vue | 709 +++++++++++ .../filters/FilterContextProvider.vue | 59 + .../components/filters/FilterDatePicker.vue | 69 ++ .../components/filters/FilterDateRange.vue | 83 ++ .../filters/FilterDateTimeRange.vue | 91 ++ src/runtime/components/filters/FilterItem.vue | 95 ++ .../components/filters/FilterNumberRange.vue | 101 ++ .../filters/FilterOperatorDropdown.vue | 105 ++ .../components/filters/FilterTimeRange.vue | 91 ++ .../filters/FilterValueSelector.vue | 443 +++++++ .../filters/SelectOptionsPopover.vue | 122 ++ src/runtime/composables/useFilterContext.ts | 53 + src/runtime/types/filter.ts | 204 ++++ src/runtime/types/index.ts | 1 + src/runtime/utils/fields.ts | 137 +++ src/runtime/utils/operators.ts | 272 +++++ src/runtime/utils/validation.ts | 105 ++ 21 files changed, 4776 insertions(+) create mode 100644 docs/content/docs/2.components/filters.md create mode 100644 playgrounds/nuxt/app/pages/components/filters.vue create mode 100644 src/runtime/components/Filters.vue create mode 100644 src/runtime/components/filters/AddFilterPopover.vue create mode 100644 src/runtime/components/filters/FilterContextProvider.vue create mode 100644 src/runtime/components/filters/FilterDatePicker.vue create mode 100644 src/runtime/components/filters/FilterDateRange.vue create mode 100644 src/runtime/components/filters/FilterDateTimeRange.vue create mode 100644 src/runtime/components/filters/FilterItem.vue create mode 100644 src/runtime/components/filters/FilterNumberRange.vue create mode 100644 src/runtime/components/filters/FilterOperatorDropdown.vue create mode 100644 src/runtime/components/filters/FilterTimeRange.vue create mode 100644 src/runtime/components/filters/FilterValueSelector.vue create mode 100644 src/runtime/components/filters/SelectOptionsPopover.vue create mode 100644 src/runtime/composables/useFilterContext.ts create mode 100644 src/runtime/types/filter.ts create mode 100644 src/runtime/utils/fields.ts create mode 100644 src/runtime/utils/operators.ts create mode 100644 src/runtime/utils/validation.ts diff --git a/docs/content/docs/2.components/filters.md b/docs/content/docs/2.components/filters.md new file mode 100644 index 0000000000..b70fb81fab --- /dev/null +++ b/docs/content/docs/2.components/filters.md @@ -0,0 +1,1061 @@ +--- +description: A component to display and manage filters (kinda looks like Linear filters) +category: data +links: + - label: GitHub + icon: i-simple-icons-github + to: https://github.com/nuxt/ui/blob/v4/src/runtime/components/Filters.vue +--- + +## Usage + +The Filters component allows you to create powerful filtering interfaces for your data. It supports over 15 field types, 30+ customizable operators, hierarchical navigation, and a [Linear-inspired interface](https://linear.app/docs/filters). + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'name' + label: 'Full name' + type: 'text' + placeholder: 'Enter a name...' + icon: 'lucide:user' + operators: + - value: 'contains' + label: 'contains' + - value: 'starts_with' + label: 'starts with' + - value: 'is' + label: 'is' + - key: 'status' + label: 'Status' + type: 'select' + options: + - value: 'active' + label: 'Active' + - value: 'inactive' + label: 'Inactive' + - value: 'pending' + label: 'Pending' + - value: 'archived' + label: 'Archived' + icon: 'lucide:check-circle' + - key: 'age' + label: 'Age' + type: 'number' + defaultOperator: 'equals' + min: 0 + max: 120 + step: 1 + placeholder: 'Enter an age...' + icon: 'lucide:calendar' + - key: 'tags' + label: 'Tags' + type: 'multiselect' + options: + - value: 'important' + label: 'Important' + - value: 'urgent' + label: 'Urgent' + - value: 'normal' + label: 'Normal' + maxSelections: 4 + icon: 'lucide:tag' + - key: 'createdAt' + label: 'Creation date' + type: 'date' + icon: 'lucide:calendar-days' + - key: 'isActive' + label: 'Active' + type: 'boolean' + onLabel: 'Yes' + offLabel: 'No' + icon: 'lucide:toggle-left' +--- +:: + +::callout{icon="i-simple-icons-github" to="https://github.com/nuxt/ui/tree/v4/playgrounds/nuxt/app/pages/components/filters.vue" aria-label="View source code"} +This example demonstrates the most common use case of the `Filters` component. Check out the source code on GitHub. +:: + +### Basic Setup + +Use the `filters` prop to manage active filters and the `fields` prop to define available filterable fields. + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'name' + label: 'Name' + type: 'text' + icon: 'lucide:user' + - key: 'status' + label: 'Status' + type: 'select' + options: + - value: 'active' + label: 'Active' + - value: 'inactive' + label: 'Inactive' +--- +:: + +### Fields Configuration + +Fields can be configured with various properties: + +- `key`: [Unique identifier for the field.]{class="text-muted"} +- `label`: [Display label for the field.]{class="text-muted"} +- `type`: [Field type (`text`, `number`, `date`, `select`, `multiselect`, `boolean`, `email`, `url`, `tel`, `time`, `datetime`, `custom`, `separator`).]{class="text-muted"} +- `icon`: [Icon to display (Nuxt Icon format, e.g., `lucide:user`).]{class="text-muted"} +- `placeholder`: [Placeholder text for input fields.]{class="text-muted"} +- `operators`: [Custom operators for this field (overrides defaults).]{class="text-muted"} +- `defaultOperator`: [Default operator to use when creating a filter.]{class="text-muted"} +- `options`: [Options for `select` and `multiselect` fields.]{class="text-muted"} +- `validation`: [Custom validation function or regex pattern.]{class="text-muted"} +- `children`: [Nested fields for hierarchical navigation.]{class="text-muted"} + +## Field Types + +### Text + +Text input field with search and optional validation. + +**Available operators:** `contains`, `not_contains`, `starts_with`, `ends_with`, `is`, `empty`, `not_empty` + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'name' + label: 'Name' + type: 'text' + placeholder: 'Enter a name...' + icon: 'lucide:user' + pattern: '^[A-Za-z]+$' + validation: (value) => { + if (typeof value !== 'string' || value.length <= 2) { + return 'Name must contain at least 3 characters' + } + } +--- +:: + +### Number + +Numeric input field with min/max/step constraints. + +**Available operators:** `equals`, `not_equals`, `greater_than`, `less_than`, `between`, `not_between`, `empty`, `not_empty` + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'age' + label: 'Age' + type: 'number' + min: 0 + max: 120 + step: 1 + placeholder: 'Enter an age...' +--- +:: + +### Number Range + +For numeric fields, when the `between` or `not_between` operator is selected, the component automatically displays two inputs (min/max) via `FilterNumberRange`. + +**Note:** There is no separate `numberrange` type. Use `type: 'number'` with the `between` or `not_between` operator. + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'priceRange' + label: 'Price' + type: 'number' + min: 0 + max: 1000 + step: 10 + defaultOperator: 'between' +--- +:: + +### Date + +Single date picker. + +**Available operators:** `before`, `after`, `is`, `is_not`, `between`, `not_between`, `empty`, `not_empty` + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'createdAt' + label: 'Created at' + type: 'date' + icon: 'lucide:calendar' +--- +:: + +### Date Range + +For date fields, when the `between` or `not_between` operator is selected, the component automatically displays two date pickers (start/end) via `FilterDateRange`. + +**Note:** There is no separate `daterange` type. Use `type: 'date'` with the `between` or `not_between` operator. + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'dateRange' + label: 'Period' + type: 'date' + icon: 'lucide:calendar-range' + defaultOperator: 'between' +--- +:: + +### Select + +Single selection dropdown. + +**Available operators:** `is`, `is_not`, `empty`, `not_empty` + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'status' + label: 'Status' + type: 'select' + options: + - value: 'active' + label: 'Active' + icon: 'lucide:check-circle' + - value: 'inactive' + label: 'Inactive' + icon: 'lucide:x-circle' + searchable: true + icon: 'lucide:check-circle' +--- +:: + +### Multiselect + +Multiple selection dropdown. + +**Available operators:** `is_any_of`, `is_not_any_of`, `includes_all`, `excludes_all`, `empty`, `not_empty` + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'tags' + label: 'Tags' + type: 'multiselect' + options: + - value: 'important' + label: 'Important' + - value: 'urgent' + label: 'Urgent' + - value: 'normal' + label: 'Normal' + maxSelections: 5 + searchable: true +--- +:: + +**Special features:** +- Real-time updates when adding/removing options +- Popover stays open to allow multiple selections +- Displays selection count in the button + +### Boolean + +On/off toggle with customizable labels. + +**Available operators:** `is`, `is_not`, `empty`, `not_empty` + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'isActive' + label: 'Active' + type: 'boolean' + onLabel: 'Yes' + offLabel: 'No' + icon: 'lucide:toggle-left' +--- +:: + +### Email + +Email input field with automatic format validation. + +**Available operators:** `contains`, `not_contains`, `starts_with`, `ends_with`, `is`, `empty`, `not_empty` + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'email' + label: 'Email' + type: 'email' + placeholder: 'email@example.com' + icon: 'lucide:mail' +--- +:: + +### URL + +URL input field with automatic format validation. + +**Available operators:** `contains`, `not_contains`, `starts_with`, `ends_with`, `is`, `empty`, `not_empty` + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'website' + label: 'Website' + type: 'url' + placeholder: 'https://example.com' + icon: 'lucide:globe' +--- +:: + +### Tel + +Phone input field with automatic format validation. + +**Available operators:** `contains`, `not_contains`, `starts_with`, `ends_with`, `is`, `empty`, `not_empty` + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'phone' + label: 'Phone' + type: 'tel' + placeholder: '+33 6 12 34 56 78' + icon: 'lucide:phone' +--- +:: + +### Time + +Time picker. + +**Available operators:** `before`, `after`, `is`, `between`, `not_between`, `empty`, `not_empty` + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'startTime' + label: 'Start time' + type: 'time' + icon: 'lucide:clock' +--- +:: + +### DateTime + +Combined date and time picker. + +**Available operators:** `before`, `after`, `is`, `between`, `not_between`, `empty`, `not_empty` + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'appointment' + label: 'Appointment' + type: 'datetime' + icon: 'lucide:calendar-clock' +--- +:: + +### Custom + +Allows using a custom Vue component for rendering. + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'customField' + label: 'Custom field' + type: 'custom' + customRenderer: defineAsyncComponent(() => import('./CustomFilter.vue')) +--- +:: + +The custom component will receive the following props: +- `field`: Field configuration +- `values`: Current values +- `onChange`: Function to update values +- `operator`: Current operator + +## Operators + +### Available Operators by Type + +| Operator | Supported Types | Description | +|----------|----------------|-------------| +| `is` | select, boolean, date, time, datetime, text, email, url, tel | Exact equality | +| `is_not` | select, boolean, date | Inequality | +| `is_any_of` | multiselect | One of the values | +| `is_not_any_of` | multiselect | None of the values | +| `includes_all` | multiselect | Includes all values | +| `excludes_all` | multiselect | Excludes all values | +| `contains` | text, email, url, tel | Contains text | +| `not_contains` | text, email, url, tel | Does not contain text | +| `starts_with` | text, email, url, tel | Starts with | +| `ends_with` | text, email, url, tel | Ends with | +| `equals` | number | Equal to | +| `not_equals` | number | Not equal to | +| `greater_than` | number | Greater than | +| `less_than` | number | Less than | +| `before` | date, time, datetime | Before | +| `after` | date, time, datetime | After | +| `between` | number, date, time, datetime | Between two values (automatically displays a range) | +| `not_between` | number, date, time, datetime | Not between (automatically displays a range) | +| `empty` | All | Is empty | +| `not_empty` | All | Is not empty | + +### Default Operator + +You can define a default operator for a field: + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'status' + label: 'Status' + type: 'select' + defaultOperator: 'is_not' + options: + - value: 'active' + label: 'Active' + - value: 'inactive' + label: 'Inactive' +--- +:: + +## Examples + +### Hierarchical Navigation + +Fields can be organized hierarchically with unlimited depth, similar to Linear's interface. + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'name-structure' + label: 'Name' + icon: 'lucide:user' + children: + - key: 'firstName' + label: 'First name' + type: 'text' + placeholder: 'Enter a first name...' + icon: 'lucide:user' + - key: 'lastName' + label: 'Last name' + type: 'text' + placeholder: 'Enter a last name...' + icon: 'lucide:user' + - key: 'contact' + label: 'Contact' + icon: 'lucide:mail' + children: + - key: 'email' + label: 'Email' + type: 'email' + placeholder: 'email@example.com' + icon: 'lucide:mail' + - key: 'phone' + label: 'Phone' + type: 'tel' + placeholder: '+33 6 12 34 56 78' + icon: 'lucide:phone' +--- +:: + +**Behavior:** +- When selecting a parent field, a new popover opens to the right with child fields +- Navigation can be infinitely nested +- Each navigation level is independent +- Search works at each level +- Popovers automatically close when selecting a final field + +### Field Grouping + +Organize fields into groups for better organization. + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - group: 'Personal Information' + fields: + - key: 'name' + label: 'Full name' + type: 'text' + placeholder: 'Enter a name...' + icon: 'lucide:user' + - key: 'age' + label: 'Age' + type: 'number' + min: 0 + max: 120 + step: 1 + placeholder: 'Enter an age...' + icon: 'lucide:calendar' + - group: 'Contact' + fields: + - key: 'email' + label: 'Email' + type: 'email' + placeholder: 'email@example.com' + icon: 'lucide:mail' + - key: 'phone' + label: 'Phone' + type: 'tel' + placeholder: '+33 6 12 34 56 78' + icon: 'lucide:phone' + - group: 'Status' + fields: + - key: 'status' + label: 'Status' + type: 'select' + options: + - value: 'active' + label: 'Active' + - value: 'inactive' + label: 'Inactive' + - value: 'pending' + label: 'Pending' + icon: 'lucide:check-circle' + - key: 'isActive' + label: 'Active' + type: 'boolean' + onLabel: 'Yes' + offLabel: 'No' + icon: 'lucide:toggle-left' +--- +:: + +Groups are displayed with headers in the selection popover. + +### Custom Operators + +Define custom operators for each field. + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'name' + label: 'Full name' + type: 'text' + placeholder: 'Enter a name...' + icon: 'lucide:user' + operators: + - value: 'contains' + label: 'contains' + - value: 'starts_with' + label: 'starts with' + - value: 'is' + label: 'is' + - key: 'age' + label: 'Age' + type: 'number' + placeholder: 'Enter an age...' + icon: 'lucide:calendar' + operators: + - value: 'equals' + label: 'equals' + - value: 'greater_than' + label: 'greater than' + - value: 'less_than' + label: 'less than' + - value: 'between' + label: 'between' +--- +:: + +### Validation + +The filter system includes automatic and customizable validation with visual error display. + +#### Automatic Validation + +`email`, `url`, and `tel` fields have built-in automatic format validation: + +- **Email**: Valid email format (e.g., `user@example.com`) +- **URL**: Valid URL format with or without http/https scheme, supports multiple subdomains (e.g., `https://example.com` or `example.com`) +- **Phone**: Valid phone format accepting digits, spaces, dashes, parentheses, and the + sign (e.g., `+33 6 12 34 56 78`) + +::component-example +--- +prettier: true +collapse: true +name: 'filters-validation-automatic-example' +highlights: + - 49 + - 57 +class: '!py-4' +--- +:: + +#### Custom Validation + +You can add custom validation via two methods: + +**1. Regex Pattern:** + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'code' + label: 'Product code' + type: 'text' + pattern: '^[A-Z]{3}-[0-9]{3}$' +--- +:: + +**2. Validation Function:** + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'score' + label: 'Score' + type: 'number' + validation: (value) => { + if (typeof value !== 'number') { + return 'Score must be a number' + } + if (value < 0) { + return 'Score cannot be negative' + } + if (value > 100) { + return 'Score cannot exceed 100' + } + } +--- +:: + +**Note:** The validation function can return: +- `undefined`, `null`, or `false`: Value is valid (no need to return explicitly) +- `true`: Value is invalid (default error message from `i18n.validation.invalid`) +- `string`: Value is invalid with custom error message + +#### Conditional Validation + +Strict validation (full format) only applies to operators requiring a complete value (`is`, `equals`, etc.). For partial search operators (`contains`, `starts_with`, etc.), full format validation is disabled to allow partial searches. + +For example, with an email field and the `starts_with` operator, you can enter "john" without error, whereas with the `is` operator, a complete email is required. + +#### Error Display + +Validation errors are displayed visually: +- Input turns red (`color="error"`) and stays red even without focus +- A tooltip appears above the input with the error message +- The tooltip appears automatically when the input is focused or hovered and there's an error + +Error messages can be customized via `i18n.validation`: + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'email' + label: 'Email' + type: 'email' + i18n: + validation: + invalidEmail: 'Invalid email format' + invalidUrl: 'Invalid URL format' + invalidTel: 'Invalid phone format' + invalid: 'Invalid input format' +--- +:: + +### Internationalization + +All texts can be customized via the `i18n` prop. The configuration is merged with default English values, so you only need to override what you want to change. + +The `i18n` prop accepts a partial `FilterI18nConfig` object. + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'name' + label: 'Nom' + type: 'text' + - key: 'status' + label: 'Statut' + type: 'select' + options: + - value: 'active' + label: 'Actif' + - value: 'inactive' + label: 'Inactif' + i18n: + addFilter: 'Ajouter un filtre' + searchFields: 'Rechercher un champ...' + noFieldsFound: 'Aucun champ trouvé.' + operators: + contains: 'contient' + is: 'est' + isNot: "n'est pas" + startsWith: 'commence par' + endsWith: 'se termine par' + empty: 'est vide' + notEmpty: "n'est pas vide" + placeholders: + enterField: (fieldType) => `Entrer ${fieldType}...` + selectField: 'Sélectionner...' + searchField: (fieldName) => `Rechercher ${fieldName.toLowerCase()}...` + validation: + invalidEmail: 'Format d\'email invalide' + invalidUrl: 'Format d\'URL invalide' + invalidTel: 'Format de téléphone invalide' + invalid: 'Format d\'entrée invalide' +--- +:: + +**Note:** The system includes English translations by default. You can override any part of the configuration, and the merge function will combine your custom values with the defaults. + +### Variants + +The component supports two visual variants: + +- `outline` (default): Visible borders +- `solid`: Colored background without borders + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +items: + variant: + - outline + - solid +props: + variant: 'outline' + filters: [] + fields: + - key: 'name' + label: 'Name' + type: 'text' +--- +:: + +### Sizes + +Three sizes are available: + +- `sm`: Small size +- `md` (default): Medium size +- `lg`: Large size + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +items: + size: + - sm + - md + - lg +props: + size: 'md' + filters: [] + fields: + - key: 'name' + label: 'Name' + type: 'text' +--- +:: + +### Radius + +- `md` (default): Rounded borders +- `full`: Fully rounded borders + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +items: + radius: + - md + - full +props: + radius: 'md' + filters: [] + fields: + - key: 'name' + label: 'Name' + type: 'text' +--- +:: + +### Custom Add Button + +You can completely customize the add button: + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + filters: [] + fields: + - key: 'name' + label: 'Name' + type: 'text' + addButtonText: 'Add filter' + addButtonIcon: 'i-lucide-plus' + addButtonClassName: 'custom-class' +--- +:: + +Or use a custom component: + +::component-example +--- +prettier: true +collapse: true +name: 'filters-custom-button-example' +highlights: + - 85 + - 95 +class: '!py-4' +--- +:: + +### Multiple Filters per Field + +By default, `allowMultiple` is enabled, allowing multiple filters for the same field. + +To disable this feature: + +::component-code +--- +collapse: true +class: '!py-4' +ignore: + - filters + - fields +props: + allowMultiple: false + filters: [] + fields: + - key: 'name' + label: 'Name' + type: 'text' +--- +:: + +When `allowMultiple` is `false`, fields already used in a filter are no longer available in the selection list. + +### Filter Management + +Filters can be added via: +1. The "Add filter" button +2. Hierarchical navigation for nested fields +3. Programmatically via the `change` event + +Each filter can be modified: +- **Field**: Not modifiable after creation (delete and recreate) +- **Operator**: Modifiable via the operator dropdown +- **Values**: Modifiable via input components + +Filters can be removed via: +- The remove button (X) on each filter +- Programmatically by filtering the filters array + +## API + +### Props + +:component-props + +### Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `change` | `Filter[]` | Emitted when filters change | + +## Changelog + +:component-changelog diff --git a/playgrounds/nuxt/app/composables/useNavigation.ts b/playgrounds/nuxt/app/composables/useNavigation.ts index 5398023167..a206c46612 100644 --- a/playgrounds/nuxt/app/composables/useNavigation.ts +++ b/playgrounds/nuxt/app/composables/useNavigation.ts @@ -31,6 +31,7 @@ const components = [ 'error', 'field-group', 'file-upload', + 'filters', 'footer', 'form-field', 'form', diff --git a/playgrounds/nuxt/app/pages/components/filters.vue b/playgrounds/nuxt/app/pages/components/filters.vue new file mode 100644 index 0000000000..bbf396a378 --- /dev/null +++ b/playgrounds/nuxt/app/pages/components/filters.vue @@ -0,0 +1,707 @@ + + + diff --git a/src/runtime/components/Filters.vue b/src/runtime/components/Filters.vue new file mode 100644 index 0000000000..1ab69cde51 --- /dev/null +++ b/src/runtime/components/Filters.vue @@ -0,0 +1,267 @@ + + + + + diff --git a/src/runtime/components/filters/AddFilterPopover.vue b/src/runtime/components/filters/AddFilterPopover.vue new file mode 100644 index 0000000000..f45a23bb9e --- /dev/null +++ b/src/runtime/components/filters/AddFilterPopover.vue @@ -0,0 +1,709 @@ + + + + diff --git a/src/runtime/components/filters/FilterContextProvider.vue b/src/runtime/components/filters/FilterContextProvider.vue new file mode 100644 index 0000000000..61113c3422 --- /dev/null +++ b/src/runtime/components/filters/FilterContextProvider.vue @@ -0,0 +1,59 @@ + + + + diff --git a/src/runtime/components/filters/FilterDatePicker.vue b/src/runtime/components/filters/FilterDatePicker.vue new file mode 100644 index 0000000000..502f5b913f --- /dev/null +++ b/src/runtime/components/filters/FilterDatePicker.vue @@ -0,0 +1,69 @@ + + + + diff --git a/src/runtime/components/filters/FilterDateRange.vue b/src/runtime/components/filters/FilterDateRange.vue new file mode 100644 index 0000000000..5bef9e0a9c --- /dev/null +++ b/src/runtime/components/filters/FilterDateRange.vue @@ -0,0 +1,83 @@ + + + + diff --git a/src/runtime/components/filters/FilterDateTimeRange.vue b/src/runtime/components/filters/FilterDateTimeRange.vue new file mode 100644 index 0000000000..3f090f6d4e --- /dev/null +++ b/src/runtime/components/filters/FilterDateTimeRange.vue @@ -0,0 +1,91 @@ + + + + diff --git a/src/runtime/components/filters/FilterItem.vue b/src/runtime/components/filters/FilterItem.vue new file mode 100644 index 0000000000..5da9201720 --- /dev/null +++ b/src/runtime/components/filters/FilterItem.vue @@ -0,0 +1,95 @@ + + + + diff --git a/src/runtime/components/filters/FilterNumberRange.vue b/src/runtime/components/filters/FilterNumberRange.vue new file mode 100644 index 0000000000..59551b4762 --- /dev/null +++ b/src/runtime/components/filters/FilterNumberRange.vue @@ -0,0 +1,101 @@ + + + + diff --git a/src/runtime/components/filters/FilterOperatorDropdown.vue b/src/runtime/components/filters/FilterOperatorDropdown.vue new file mode 100644 index 0000000000..4c0f7b964b --- /dev/null +++ b/src/runtime/components/filters/FilterOperatorDropdown.vue @@ -0,0 +1,105 @@ + + + + diff --git a/src/runtime/components/filters/FilterTimeRange.vue b/src/runtime/components/filters/FilterTimeRange.vue new file mode 100644 index 0000000000..ee4b8926f3 --- /dev/null +++ b/src/runtime/components/filters/FilterTimeRange.vue @@ -0,0 +1,91 @@ + + + + diff --git a/src/runtime/components/filters/FilterValueSelector.vue b/src/runtime/components/filters/FilterValueSelector.vue new file mode 100644 index 0000000000..23b8a653b9 --- /dev/null +++ b/src/runtime/components/filters/FilterValueSelector.vue @@ -0,0 +1,443 @@ + + + + diff --git a/src/runtime/components/filters/SelectOptionsPopover.vue b/src/runtime/components/filters/SelectOptionsPopover.vue new file mode 100644 index 0000000000..bdab63dde7 --- /dev/null +++ b/src/runtime/components/filters/SelectOptionsPopover.vue @@ -0,0 +1,122 @@ + + + + diff --git a/src/runtime/composables/useFilterContext.ts b/src/runtime/composables/useFilterContext.ts new file mode 100644 index 0000000000..8a3b827d0d --- /dev/null +++ b/src/runtime/composables/useFilterContext.ts @@ -0,0 +1,53 @@ +import { inject } from 'vue' +import type { + FilterI18nConfig, + FiltersVariant, + FiltersSize, + FiltersRadius +} from '../types/filter' +import type { Component } from 'vue' + +/** + * Composable and context for the filter system + * Allows sharing configuration (variant, size, i18n) between all child components + */ + +/** + * Value of the context shared between all filter components + * Contains visual and functional configuration + */ +export interface FilterContextValue { + variant: FiltersVariant + size: FiltersSize + radius: FiltersRadius + i18n: FilterI18nConfig + cursorPointer: boolean + className?: string + showAddButton?: boolean + addButtonText?: string + addButtonIcon?: string + addButtonClassName?: string + addButton?: Component + showSearchInput?: boolean + trigger?: Component + allowMultiple?: boolean // Allows multiple filters on the same field +} + +/** + * Unique symbol for Vue context injection + * Ensures context uniqueness in the component tree + */ +export const FilterContext = Symbol('FilterContext') as symbol + +/** + * Composable to access the filter context + * @throws Error if used outside a FilterContextProvider + * @returns The filter context configuration + */ +export function useFilterContext(): FilterContextValue { + const context = inject(FilterContext) + if (!context) { + throw new Error('useFilterContext must be used within FilterContextProvider') + } + return context +} diff --git a/src/runtime/types/filter.ts b/src/runtime/types/filter.ts new file mode 100644 index 0000000000..88df88f5ea --- /dev/null +++ b/src/runtime/types/filter.ts @@ -0,0 +1,204 @@ +import type { Component } from 'vue' + +/** + * Field types supported by the filter system + * Each type determines available operators and the input component used + */ +export type FilterFieldType + = | 'select' + | 'multiselect' + | 'date' + | 'text' + | 'number' + | 'boolean' + | 'email' + | 'url' + | 'tel' + | 'time' + | 'datetime' + | 'custom' + | 'separator' + +/** + * Possible values for filter operators + * Covers comparison operations, text search, ranges, etc. + */ +export type FilterOperatorValue + = | 'is' + | 'is_not' + | 'is_any_of' + | 'is_not_any_of' + | 'includes_all' + | 'excludes_all' + | 'before' + | 'after' + | 'between' + | 'not_between' + | 'contains' + | 'not_contains' + | 'starts_with' + | 'ends_with' + | 'is_exactly' + | 'equals' + | 'not_equals' + | 'greater_than' + | 'less_than' + | 'overlaps' + | 'includes' + | 'excludes' + | 'includes_all_of' + | 'includes_any_of' + | 'empty' + | 'not_empty' + +/** + * Visual variants for filter components + */ +export type FiltersVariant = 'solid' | 'outline' +export type FiltersSize = 'sm' | 'md' | 'lg' +export type FiltersRadius = 'md' | 'full' + +/** + * Option available for a select or multiselect field type + * @template T - Type of the option value + */ +export interface FilterOption { + value: T + label: string + icon?: string | Component +} + +/** + * Definition of a filter operator with its label and capabilities + */ +export interface FilterOperator { + value: FilterOperatorValue + label: string + supportsMultiple?: boolean +} + +/** + * Complete configuration of a filterable field + * Defines the type, options, available operators, validation constraints, etc. + * @template T - Type of field values + */ +export interface FilterFieldConfig { + key: string + label: string + icon?: string | Component + type?: FilterFieldType + options?: FilterOption[] + operators?: FilterOperator[] + placeholder?: string + searchable?: boolean + className?: string + defaultOperator?: FilterOperatorValue + popoverContentClassName?: string + maxSelections?: number + min?: number + max?: number + step?: number + prefix?: string | Component + suffix?: string | Component + pattern?: string + validation?: (value: unknown) => boolean | string | null | undefined + allowCustomValues?: boolean + onLabel?: string + offLabel?: string + customRenderer?: Component + children?: FilterFieldConfig[] // Allows creation of nested/hierarchical fields +} + +/** + * Field group for organization in the interface + * Allows grouping fields by category + */ +export interface FilterFieldGroup { + group: string + fields: FilterFieldConfig[] +} + +/** + * Configuration of filterable fields + * Can be either a simple array of fields, or an array of groups + */ +export type FilterFieldsConfig = FilterFieldConfig[] | FilterFieldGroup[] + +/** + * Internationalization configuration for the filter system + */ +export interface FilterI18nConfig { + addFilter: string + searchFields: string + noFieldsFound: string + noResultsFound: string + select: string + true: string + false: string + min: string + max: string + to: string + toAlt: string + typeAndPressEnter: string + selected: string + selectedCount: string + percent: string + defaultCurrency: string + defaultColor: string + addFilterTitle: string + operators: { + is: string + isNot: string + isAnyOf: string + isNotAnyOf: string + includesAll: string + excludesAll: string + before: string + after: string + between: string + notBetween: string + contains: string + notContains: string + startsWith: string + endsWith: string + isExactly: string + equals: string + notEquals: string + greaterThan: string + lessThan: string + overlaps: string + includes: string + excludes: string + includesAllOf: string + includesAnyOf: string + empty: string + notEmpty: string + } + placeholders: { // Contextual help texts for input fields + enterField: (fieldType: string) => string + selectField: string + searchField: (fieldName: string) => string + enterKey: string + enterValue: string + } + helpers: { + formatOperator: (operator: string) => string + } + validation: { + invalidEmail: string + invalidUrl: string + invalidTel: string + invalid: string + } +} + +/** + * Represents an active filter with its field, operator and values + * @template T - Type of filter values (default unknown for flexibility) + */ +export interface Filter { + id: string + field: string + operator: FilterOperatorValue + values: T[] +} diff --git a/src/runtime/types/index.ts b/src/runtime/types/index.ts index 8dc0924ce2..823a1efc4e 100644 --- a/src/runtime/types/index.ts +++ b/src/runtime/types/index.ts @@ -111,3 +111,4 @@ export * from '../components/locale/LocaleSelect.vue' export * from './form' export * from './locale' export * from './tv' +export * from './filter' diff --git a/src/runtime/utils/fields.ts b/src/runtime/utils/fields.ts new file mode 100644 index 0000000000..95836a0f5b --- /dev/null +++ b/src/runtime/utils/fields.ts @@ -0,0 +1,137 @@ +import type { FilterFieldsConfig, FilterFieldConfig, FilterFieldGroup } from '../types/filter' + +/** + * Utilities for manipulating field configurations + * Handles flattening, searching, and grouping of fields + */ + +/** + * Recursively processes a list of fields by applying a callback function + * Allows traversing fields and their children recursively + */ +function processFieldsRecursively( + fields: FilterFieldConfig[], + onField: (field: FilterFieldConfig) => void +) { + for (const field of fields) { + onField(field) + if (field.children && field.children.length > 0) { + processFieldsRecursively(field.children, onField) + } + } +} + +/** + * Flattens a field configuration into a simple array + * Converts groups and nested fields into a flat list + * @param config - Field configuration (simple or grouped) + * @returns Flat array of all fields that have a defined type + */ +export function flattenFieldsConfig( + config: FilterFieldsConfig +): FilterFieldConfig[] { + const flatFields: FilterFieldConfig[] = [] + + const collect = (fields: FilterFieldConfig[]) => { + processFieldsRecursively(fields, (field) => { + if (field.type) { + flatFields.push(field) + } + }) + } + + if (Array.isArray(config)) { + collect(config as FilterFieldConfig[]) + } else { + const groups = config as FilterFieldGroup[] + for (const group of groups) { + collect(group.fields) + } + } + + return flatFields +} + +/** + * Searches for a field by its key in the configuration + * @param config - Field configuration + * @param key - Key of the field to search for + * @returns The found field configuration, or undefined + */ +export function findFieldConfig( + config: FilterFieldsConfig, + key: string +): FilterFieldConfig | undefined { + const fields = flattenFieldsConfig(config) + return fields.find(field => field.key === key) +} + +/** + * Extracts groups from a field configuration + * @param config - Field configuration + * @returns Array of groups (empty if config is not grouped) + */ +export function getFieldGroups(config: FilterFieldsConfig): FilterFieldGroup[] { + if (Array.isArray(config)) { + return [] + } + return config as FilterFieldGroup[] +} + +/** + * Checks if a configuration contains groups + * @param config - Field configuration + * @returns true if the configuration is grouped, false otherwise + */ +export function hasGroups(config: FilterFieldsConfig): boolean { + if (Array.isArray(config)) { + return false + } + const groups = config as FilterFieldGroup[] + return groups.length > 0 && groups[0] !== undefined && 'group' in groups[0] +} + +/** + * Creates a map of fields indexed by their key for quick access + * @param config - Field configuration + * @returns Object with field keys as properties + */ +export function getFieldsMap(config: FilterFieldsConfig): Record { + const flatFields = flattenFieldsConfig(config) + return flatFields.reduce( + (acc, field) => { + if (field.key) { + acc[field.key] = field + } + return acc + }, + {} as Record + ) +} + +/** + * Collects all fields from a configuration, including those without a type + * Differs from flattenFieldsConfig which only returns fields with a type + * @param config - Field configuration + * @returns Array of all fields (including separators and parent fields) + */ +export function collectAllFields(config: FilterFieldsConfig): FilterFieldConfig[] { + const allFields: FilterFieldConfig[] = [] + + const collect = (fields: FilterFieldConfig[]) => { + processFieldsRecursively(fields, (field) => { + allFields.push(field) + }) + } + + if (Array.isArray(config)) { + collect(config as FilterFieldConfig[]) + } else { + const groups = config as FilterFieldGroup[] + for (const group of groups) { + collect(group.fields) + } + } + + return allFields +} diff --git a/src/runtime/utils/operators.ts b/src/runtime/utils/operators.ts new file mode 100644 index 0000000000..23e11f038b --- /dev/null +++ b/src/runtime/utils/operators.ts @@ -0,0 +1,272 @@ +import type { + FilterFieldType, + FilterOperator, + FilterOperatorValue, + FilterI18nConfig, + FiltersSize +} from '../types/filter' + +/** + * Utilities for managing filter operators + * Defines default operators for each field type and handles internationalization + */ + +/** + * Default operators available for each field type + * Each field type has a set of logical operators adapted to its usage + */ +export const defaultOperators: Record = { + text: [ + { value: 'contains', label: 'contains' }, + { value: 'not_contains', label: 'does not contain' }, + { value: 'starts_with', label: 'starts with' }, + { value: 'ends_with', label: 'ends with' }, + { value: 'is', label: 'is' }, + { value: 'empty', label: 'is empty' }, + { value: 'not_empty', label: 'is not empty' } + ], + number: [ + { value: 'equals', label: 'equals' }, + { value: 'not_equals', label: 'not equals' }, + { value: 'greater_than', label: 'greater than' }, + { value: 'less_than', label: 'less than' }, + { value: 'between', label: 'between' }, + { value: 'not_between', label: 'not between' }, + { value: 'empty', label: 'is empty' }, + { value: 'not_empty', label: 'is not empty' } + ], + date: [ + { value: 'before', label: 'before' }, + { value: 'after', label: 'after' }, + { value: 'is', label: 'is' }, + { value: 'is_not', label: 'is not' }, + { value: 'between', label: 'between' }, + { value: 'not_between', label: 'not between' }, + { value: 'empty', label: 'is empty' }, + { value: 'not_empty', label: 'is not empty' } + ], + select: [ + { value: 'is', label: 'is' }, + { value: 'is_not', label: 'is not' }, + { value: 'empty', label: 'is empty' }, + { value: 'not_empty', label: 'is not empty' } + ], + multiselect: [ + { value: 'is_any_of', label: 'is any of' }, + { value: 'is_not_any_of', label: 'is not any of' }, + { value: 'includes_all', label: 'includes all' }, + { value: 'excludes_all', label: 'excludes all' }, + { value: 'empty', label: 'is empty' }, + { value: 'not_empty', label: 'is not empty' } + ], + boolean: [ + { value: 'is', label: 'is' }, + { value: 'is_not', label: 'is not' }, + { value: 'empty', label: 'is empty' }, + { value: 'not_empty', label: 'is not empty' } + ], + email: [ + { value: 'contains', label: 'contains' }, + { value: 'not_contains', label: 'does not contain' }, + { value: 'starts_with', label: 'starts with' }, + { value: 'ends_with', label: 'ends with' }, + { value: 'is', label: 'is' }, + { value: 'empty', label: 'is empty' }, + { value: 'not_empty', label: 'is not empty' } + ], + url: [ + { value: 'contains', label: 'contains' }, + { value: 'not_contains', label: 'does not contain' }, + { value: 'starts_with', label: 'starts with' }, + { value: 'ends_with', label: 'ends with' }, + { value: 'is', label: 'is' }, + { value: 'empty', label: 'is empty' }, + { value: 'not_empty', label: 'is not empty' } + ], + tel: [ + { value: 'contains', label: 'contains' }, + { value: 'not_contains', label: 'does not contain' }, + { value: 'starts_with', label: 'starts with' }, + { value: 'ends_with', label: 'ends with' }, + { value: 'is', label: 'is' }, + { value: 'empty', label: 'is empty' }, + { value: 'not_empty', label: 'is not empty' } + ], + time: [ + { value: 'before', label: 'before' }, + { value: 'after', label: 'after' }, + { value: 'is', label: 'is' }, + { value: 'between', label: 'between' }, + { value: 'not_between', label: 'not between' }, + { value: 'empty', label: 'is empty' }, + { value: 'not_empty', label: 'is not empty' } + ], + datetime: [ + { value: 'before', label: 'before' }, + { value: 'after', label: 'after' }, + { value: 'is', label: 'is' }, + { value: 'between', label: 'between' }, + { value: 'not_between', label: 'not between' }, + { value: 'empty', label: 'is empty' }, + { value: 'not_empty', label: 'is not empty' } + ], + custom: [], + separator: [] // Separators don't have operators +} + +/** + * Gets available operators for a given field type + * Uses custom operators if provided, otherwise default operators + * @param fieldType - Field type to get operators for + * @param customOperators - Optional custom operators for this field + * @returns List of available operators for this field type + */ +export function getOperatorsForFieldType( + fieldType: FilterFieldType, + customOperators?: FilterOperator[] +): FilterOperator[] { + if (customOperators && customOperators.length > 0) { + return customOperators + } + return defaultOperators[fieldType] || [] +} + +/** + * Determines the default operator for a field type + * @param fieldType - Field type + * @param customDefault - Optional custom default operator + * @returns The default operator (first in the list or 'is' if none) + */ +export function getDefaultOperatorForFieldType( + fieldType: FilterFieldType, + customDefault?: FilterOperatorValue +): FilterOperatorValue { + if (customDefault) { + return customDefault + } + const operators = defaultOperators[fieldType] + if (operators && operators.length > 0 && operators[0]) { + return operators[0].value + } + return 'is' +} + +/** + * Default internationalization configuration (English) + * Contains all texts displayed in the filter interface + */ +export const defaultI18n: FilterI18nConfig = { + addFilter: 'Filter', + searchFields: 'Search...', + noFieldsFound: 'No fields found.', + noResultsFound: 'No results found.', + select: 'Select...', + true: 'True', + false: 'False', + min: 'Min', + max: 'Max', + to: 'to', + toAlt: 'and', + typeAndPressEnter: 'Type and press Enter to add a tag', + selected: 'selected', + selectedCount: 'selected', + percent: '%', + defaultCurrency: '$', + defaultColor: '#000000', + addFilterTitle: '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 a key...', + enterValue: 'Enter a 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' + } +} + +/** + * Merges a custom i18n configuration with the default configuration + * Custom values override default values + * @param custom - Partial custom i18n configuration + * @returns Complete merged i18n configuration + */ +export function mergeI18nConfig( + custom?: Partial +): FilterI18nConfig { + if (!custom) { + return defaultI18n + } + + return { + ...defaultI18n, + ...custom, + operators: { + ...defaultI18n.operators, + ...(custom.operators || {}) + }, + placeholders: { + ...defaultI18n.placeholders, + ...(custom.placeholders || {}) + }, + helpers: { + ...defaultI18n.helpers, + ...(custom.helpers || {}) + }, + validation: { + ...defaultI18n.validation, + ...(custom.validation || {}) + } + } +} + +/** + * Determines badge size based on filter size + * @param size - Filter size + * @returns Badge size + */ +export function getBadgeSize(size: FiltersSize): 'md' | 'lg' | 'xl' { + switch (size) { + case 'sm': + return 'md' + case 'md': + return 'lg' + case 'lg': + return 'xl' + } +} diff --git a/src/runtime/utils/validation.ts b/src/runtime/utils/validation.ts new file mode 100644 index 0000000000..a83f1499d5 --- /dev/null +++ b/src/runtime/utils/validation.ts @@ -0,0 +1,105 @@ +import type { Filter } from '../types/filter' + +/** + * Validation utilities and ID generation for filters + * Provides functions to validate filters and their values + */ + +/** + * Generates a unique identifier for a filter + * Combines timestamp and random string to ensure uniqueness + * @returns Unique identifier in format "filter-{timestamp}-{random}" + */ +export function generateFilterId(): string { + return `filter-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` +} + +/** + * Validates that a filter contains all required properties + * @param filter - Filter to validate + * @returns true if the filter is valid, false otherwise + */ +export function validateFilter(filter: Filter): boolean { + if (!filter.id || !filter.field || !filter.operator) { + return false + } + return true +} + +/** + * Result of a validation with optional error message + */ +export interface ValidationResult { + isValid: boolean + errorMessage?: string | null +} + +/** + * Validates a filter value according to a regex pattern or custom validation function + * @param value - Value to validate + * @param pattern - Optional regex pattern for validation + * @param validation - Optional custom validation function + * - Returns `undefined`, `null` or `false`: value is valid + * - Returns `true`: value is invalid (default error message) + * - Returns `string`: value is invalid with custom error message + * @returns Validation result with optional error message + */ +export function validateFilterValue( + value: unknown, + pattern?: string, + validation?: (value: unknown) => boolean | string | null | undefined +): ValidationResult { + if (validation) { + const result = validation(value) + // undefined, null or false = valid (no need to return explicitly) + if (result === undefined || result === null || result === false) { + return { isValid: true } + } + // string = invalid with custom message + if (typeof result === 'string') { + return { isValid: false, errorMessage: result } + } + // true = invalid without custom message + return { isValid: false } + } + if (pattern && typeof value === 'string') { + const regex = new RegExp(pattern) + const isValid = regex.test(value) + return { isValid } + } + return { isValid: true } +} + +/** + * Validates email format + * @param email - Email address to validate + * @returns true if email is valid, false otherwise + */ +export function validateEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/ + return emailRegex.test(email) +} + +/** + * Validates URL format via regular expression + * Allows URLs with or without schema (http, https) + * Supports multiple subdomains (N subdomains) + * @param url - URL to validate + * @returns true if URL appears valid, false otherwise + */ +export function validateUrl(url: string): boolean { + // Supports http(s)://, N subdomains, TLD 2+ characters, optionally a path + const urlRegex = /^(?:https?:\/\/)?(?:[\w-]+\.)+[\w-]{2,}(?:\/\S*)?$/i + return urlRegex.test(url) +} + +/** + * Validates phone number format + * Accepts digits, spaces, dashes, parentheses and the + sign + * @param tel - Phone number to validate + * @returns true if number is valid, false otherwise + */ +export function validateTel(tel: string): boolean { + const telRegex = /^[\d\s\-+()]+$/ + return telRegex.test(tel) +} From b8e17a35ad19c9743e4ea419b00563c5d394684a Mon Sep 17 00:00:00 2001 From: Maxime Princelle Date: Mon, 10 Nov 2025 17:05:48 +0100 Subject: [PATCH 2/3] doc: clean up doc --- docs/content/docs/2.components/filters.md | 103 +--------------------- 1 file changed, 1 insertion(+), 102 deletions(-) diff --git a/docs/content/docs/2.components/filters.md b/docs/content/docs/2.components/filters.md index b70fb81fab..f4213817c2 100644 --- a/docs/content/docs/2.components/filters.md +++ b/docs/content/docs/2.components/filters.md @@ -774,7 +774,7 @@ props: label: 'Score' type: 'number' validation: (value) => { - if (typeof value !== 'number') { + if (!/^\d+(\.\d+)?$/.test(String(value))) { return 'Score must be a number' } if (value < 0) { @@ -889,27 +889,6 @@ The component supports two visual variants: - `outline` (default): Visible borders - `solid`: Colored background without borders -::component-code ---- -collapse: true -class: '!py-4' -ignore: - - filters - - fields -items: - variant: - - outline - - solid -props: - variant: 'outline' - filters: [] - fields: - - key: 'name' - label: 'Name' - type: 'text' ---- -:: - ### Sizes Three sizes are available: @@ -918,91 +897,11 @@ Three sizes are available: - `md` (default): Medium size - `lg`: Large size -::component-code ---- -collapse: true -class: '!py-4' -ignore: - - filters - - fields -items: - size: - - sm - - md - - lg -props: - size: 'md' - filters: [] - fields: - - key: 'name' - label: 'Name' - type: 'text' ---- -:: - ### Radius - `md` (default): Rounded borders - `full`: Fully rounded borders -::component-code ---- -collapse: true -class: '!py-4' -ignore: - - filters - - fields -items: - radius: - - md - - full -props: - radius: 'md' - filters: [] - fields: - - key: 'name' - label: 'Name' - type: 'text' ---- -:: - -### Custom Add Button - -You can completely customize the add button: - -::component-code ---- -collapse: true -class: '!py-4' -ignore: - - filters - - fields -props: - filters: [] - fields: - - key: 'name' - label: 'Name' - type: 'text' - addButtonText: 'Add filter' - addButtonIcon: 'i-lucide-plus' - addButtonClassName: 'custom-class' ---- -:: - -Or use a custom component: - -::component-example ---- -prettier: true -collapse: true -name: 'filters-custom-button-example' -highlights: - - 85 - - 95 -class: '!py-4' ---- -:: - ### Multiple Filters per Field By default, `allowMultiple` is enabled, allowing multiple filters for the same field. From 81d2b980e44a34cdb6646d86e766b3f64cfd9972 Mon Sep 17 00:00:00 2001 From: Maxime Princelle Date: Mon, 10 Nov 2025 17:06:08 +0100 Subject: [PATCH 3/3] refactor(Filters): simplify default operator logic and enhance filter context injection --- src/runtime/components/Filters.vue | 9 +++------ src/runtime/composables/useFilterContext.ts | 6 +++--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/runtime/components/Filters.vue b/src/runtime/components/Filters.vue index 1ab69cde51..0f0a943e7f 100644 --- a/src/runtime/components/Filters.vue +++ b/src/runtime/components/Filters.vue @@ -58,7 +58,7 @@ import type { FilterOperatorValue } from '../types/filter' import { computed } from 'vue' -import { mergeI18nConfig } from '../utils/operators' +import { mergeI18nConfig, getDefaultOperatorForFieldType } from '../utils/operators' import { flattenFieldsConfig, getFieldsMap } from '../utils/fields' import FilterContextProvider from './filters/FilterContextProvider.vue' import FilterItem from './filters/FilterItem.vue' @@ -210,13 +210,10 @@ function handleAddFilter(fieldKey: string, operator?: FilterOperatorValue, value /** * Determines the default operator for a given field - * For booleans, always returns 'is' + * Uses the utility function to get the correct default operator for each field type */ function getDefaultOperatorForField(field: FilterFieldConfig): FilterOperatorValue { - if (field.type === 'boolean') { - return 'is' - } - return 'is' + return getDefaultOperatorForFieldType(field.type, field.defaultOperator) } /** diff --git a/src/runtime/composables/useFilterContext.ts b/src/runtime/composables/useFilterContext.ts index 8a3b827d0d..e2fad909ae 100644 --- a/src/runtime/composables/useFilterContext.ts +++ b/src/runtime/composables/useFilterContext.ts @@ -1,4 +1,4 @@ -import { inject } from 'vue' +import { inject, unref } from 'vue' import type { FilterI18nConfig, FiltersVariant, @@ -45,9 +45,9 @@ export const FilterContext = Symbol('FilterContext') as symbol * @returns The filter context configuration */ export function useFilterContext(): FilterContextValue { - const context = inject(FilterContext) + const context = inject(FilterContext) if (!context) { throw new Error('useFilterContext must be used within FilterContextProvider') } - return context + return unref(context) }