@abaktiar/ql-input v1.5.0
@abaktiar/ql-input
A comprehensive React QL (Query Language) input component with intelligent autocomplete, query validation, and a beautiful UI built with TypeScript and shadcn/ui components.
๐ Live Demo - Try the component in action!
๐ What's New in v1.5.0
โจ Smart Suggestion Behavior - Enhanced UX with manual control and Ctrl+Space
shortcuts
๐ Smooth & Minimal Design - Lightning-fast 80ms transitions with beautiful visual states
โจ๏ธ Enhanced Keyboard Navigation - Auto-scroll to selection with seamless long-list navigation
๐ฏ Perfect Accessibility - Unified mouse and keyboard interactions with smooth scrolling
๐ง Parameterized Functions - Advanced function support with type validation and error handling
โจ Features
Core Functionality
- ๐ Context-aware autocomplete - Smart suggestions based on query context
- ๐จ Visual feedback - Query validation status and condition count
- โจ๏ธ Keyboard navigation - Full keyboard support (โโ Enter Esc Tab)
- ๐ง Configurable fields - Define custom fields, operators, and values
- ๐ Query parsing - Complete QL query structure parsing
- ๐ฏ Parentheses grouping - Support for complex condition grouping
Advanced Features
- ๐ Async value suggestions - Server-side value fetching with debouncing
- ๐ Function support - Built-in functions like
currentUser()
,now()
, and parameterized functions likedaysAgo(30)
,userInRole("admin")
- ๐ ORDER BY clauses - Sorting with ASC/DESC support
- ๐ Dark mode - Full theming support
- โฟ Accessibility - ARIA attributes for screen readers
- ๐ฑ Responsive design - Works across different screen sizes
๐ฆ Installation
npm install @abaktiar/ql-input
yarn add @abaktiar/ql-input
pnpm add @abaktiar/ql-input
Peer Dependencies
Make sure you have the required peer dependencies installed:
npm install react react-dom
๐ Quick Start
Basic Setup
import React from 'react';
import { QLInput } from '@abaktiar/ql-input';
import '@abaktiar/ql-input/styles.css';
import type { QLInputConfig, QLQuery } from '@abaktiar/ql-input';
const config: QLInputConfig = {
fields: [
{
name: 'status',
displayName: 'Status',
type: 'option',
operators: ['=', '!=', 'IN', 'NOT IN'],
options: [
{ value: 'open', label: 'Open' },
{ value: 'closed', label: 'Closed' },
{ value: 'pending', label: 'Pending' }
]
},
{
name: 'assignee',
displayName: 'Assignee',
type: 'user',
operators: ['=', '!=', 'IS EMPTY', 'IS NOT EMPTY']
},
{
name: 'created',
displayName: 'Created Date',
type: 'date',
operators: ['=', '>', '<', '>=', '<='],
sortable: true
}
],
maxSuggestions: 10,
caseSensitive: false,
allowParentheses: true,
allowOrderBy: true,
allowFunctions: true
};
function MyComponent() {
const handleChange = (value: string, query: QLQuery) => {
console.log('Query changed:', value);
console.log('Parsed query:', query);
};
const handleExecute = (query: QLQuery) => {
console.log('Query executed:', query);
// Handle query execution (e.g., API call)
};
return (
<QLInput
config={config}
placeholder="Enter your query..."
onChange={handleChange}
onExecute={handleExecute}
/>
);
}
Framework Independent
The component is completely framework-independent and doesn't require Tailwind CSS or any other CSS framework. Simply import the CSS file and you're ready to go:
@import '@abaktiar/ql-input/styles.css';
The component includes comprehensive styling with CSS custom properties for easy theming and dark mode support.
๐ฏ Component Props
QLInput Props
interface QLInputProps {
// Configuration
config: QLInputConfig;
// Value control
value?: string;
onChange?: (value: string, query: QLQuery) => void;
onExecute?: (query: QLQuery) => void;
// Appearance
placeholder?: string;
disabled?: boolean;
className?: string;
showSearchIcon?: boolean; // Default: true
showClearIcon?: boolean; // Default: true
// Async suggestions
getAsyncValueSuggestions?: (field: string, typedValue: string) => Promise<QLValue[]>;
getPredefinedValueSuggestions?: (field: string) => QLValue[];
}
Configuration Options
interface QLInputConfig {
fields: QLField[];
maxSuggestions?: number;
caseSensitive?: boolean;
allowParentheses?: boolean;
allowOrderBy?: boolean;
allowFunctions?: boolean;
functions?: QLFunction[];
}
๐ก Usage Examples
Controlled Component
import React, { useState } from 'react';
import { QLInput } from '@abaktiar/ql-input';
function ControlledExample() {
const [query, setQuery] = useState('status = "open"');
return (
<QLInput
config={config}
value={query}
onChange={(value) => setQuery(value)}
placeholder="Search issues..."
/>
);
}
With Async Value Suggestions
import React from 'react';
import { QLInput } from '@abaktiar/ql-input';
function AsyncExample() {
const getAsyncValueSuggestions = async (field: string, typedValue: string) => {
if (field === 'assignee') {
// Fetch users from API
const response = await fetch(`/api/users?search=${typedValue}`);
const users = await response.json();
return users.map(user => ({
value: user.id,
label: user.name
}));
}
return [];
};
return (
<QLInput
config={config}
getAsyncValueSuggestions={getAsyncValueSuggestions}
placeholder="Search with async suggestions..."
/>
);
}
With Custom Functions
import React from 'react';
import { QLInput } from '@abaktiar/ql-input';
const configWithFunctions: QLInputConfig = {
fields: [
// ... your fields
],
allowFunctions: true,
functions: [
{
name: 'currentUser',
displayName: 'currentUser()',
description: 'The currently logged-in user',
},
{
name: 'daysAgo',
displayName: 'daysAgo(days)',
description: 'Date N days ago from today',
parameters: [{
name: 'days',
type: 'number',
required: true,
description: 'Number of days'
}]
},
{
name: 'userInRole',
displayName: 'userInRole(role)',
description: 'Users with specific role',
parameters: [{
name: 'role',
type: 'text',
required: true,
description: 'User role name'
}]
},
{
name: 'dateRange',
displayName: 'dateRange(start, end)',
description: 'Date range between two dates',
parameters: [
{
name: 'startDate',
type: 'date',
required: true,
description: 'Start date'
},
{
name: 'endDate',
type: 'date',
required: true,
description: 'End date'
}
]
}
]
};
function CustomFunctionsExample() {
return (
<QLInput
config={configWithFunctions}
placeholder="Try: assignee = userInRole("admin") AND created >= daysAgo(30)"
/>
);
}
๐ฏ Query Examples
The component supports powerful QL queries with parameterized functions:
Basic Function Queries
-- User functions
assignee = currentUser()
assignee = userInRole("admin")
assignee IN (currentUser(), userInRole("manager"))
-- Date functions
created >= daysAgo(30)
updated <= daysFromNow(7)
created = dateRange("2023-01-01", "2023-12-31")
-- Project functions
project = projectsWithPrefix("PROJ")
Complex Expressions
-- Combined conditions with functions
assignee = currentUser() AND created >= daysAgo(30)
-- Functions in IN lists
assignee IN (currentUser(), userInRole("admin")) AND priority = High
-- Complex grouping with functions
(created >= daysAgo(30) AND assignee = currentUser()) OR priority = Highest
-- Multiple function parameters
created = dateRange("2023-01-01", daysAgo(90)) AND status = Open
Real-World Use Cases
-- Find my recent work
assignee = currentUser() AND updated >= daysAgo(7)
-- Admin oversight
assignee = userInRole("admin") AND status = "In Progress"
-- Project timeline queries
project = projectsWithPrefix("PROJ") AND created >= dateRange("2023-01-01", "2023-12-31")
-- Team management
assignee IN (userInRole("developer"), userInRole("qa")) AND priority >= High
Form Integration
import React from 'react';
import { useForm } from 'react-hook-form';
import { QLInput } from '@abaktiar/ql-input';
interface FormData {
query: string;
// ... other fields
}
function FormExample() {
const { register, handleSubmit, setValue, watch } = useForm<FormData>();
const queryValue = watch('query');
const onSubmit = (data: FormData) => {
console.log('Form submitted:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<QLInput
config={config}
value={queryValue}
onChange={(value) => setValue('query', value)}
placeholder="Enter search query..."
/>
<button type="submit">Search</button>
</form>
);
}
Dark Mode Support
import React from 'react';
import { QLInput } from '@abaktiar/ql-input';
function DarkModeExample() {
return (
<div className="dark"> {/* or use ql-dark, or data-theme="dark" */}
<QLInput
config={config}
placeholder="Dark mode supported automatically..."
/>
</div>
);
}
The component automatically detects dark mode using these selectors:
.dark
(Tailwind CSS convention).ql-dark
(component-specific)[data-theme="dark"]
(data attribute approach)
๐จ Styling & Customization
CSS Custom Properties
The component uses CSS custom properties for comprehensive theming:
:root {
/* Colors */
--ql-input-border: #e2e8f0;
--ql-input-background: #ffffff;
--ql-input-foreground: #1a202c;
--ql-input-muted: #f7fafc;
--ql-input-muted-foreground: #718096;
--ql-input-ring: #3182ce;
--ql-input-destructive: #e53e3e;
--ql-input-success: #38a169;
--ql-input-warning: #d69e2e;
/* Spacing and sizing */
--ql-input-radius: 0.375rem;
--ql-input-spacing-sm: 0.5rem;
--ql-input-spacing-md: 0.75rem;
/* Typography */
--ql-input-font-size-sm: 0.875rem;
--ql-input-line-height: 1.5;
/* Transitions */
--ql-input-transition: all 0.2s ease-in-out;
}
/* Dark theme */
.dark .ql-input-container,
.ql-dark,
[data-theme="dark"] .ql-input-container {
--ql-input-border: #4a5568;
--ql-input-background: #2d3748;
--ql-input-foreground: #f7fafc;
--ql-input-muted: #4a5568;
--ql-input-muted-foreground: #a0aec0;
--ql-input-ring: #63b3ed;
--ql-input-destructive: #fc8181;
--ql-input-success: #68d391;
--ql-input-warning: #f6e05e;
}
Custom Styling
You can customize the component by overriding CSS custom properties:
/* Custom theme */
.my-custom-theme {
--ql-input-border: #3b82f6;
--ql-input-ring: #1d4ed8;
--ql-input-background: #f8fafc;
--ql-input-radius: 0.75rem;
}
import React from 'react';
import { QLInput } from '@abaktiar/ql-input';
function StyledExample() {
return (
<div className="my-custom-theme">
<QLInput
config={config}
placeholder="Custom styled input..."
/>
</div>
);
}
๐ง Advanced Usage
Using the Hook Directly
import React from 'react';
import { useQLInput } from '@abaktiar/ql-input';
function CustomInputComponent() {
const { state, handleInputChange, handleKeyDown, selectSuggestion } = useQLInput({
config,
onChange: (value, query) => console.log(value, query)
});
return (
<div>
<input
value={state.value}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
/>
{state.showSuggestions && (
<div>
{state.suggestions.map((suggestion, index) => (
<div
key={index}
onClick={() => selectSuggestion(suggestion)}
className={index === state.selectedSuggestionIndex ? 'selected' : ''}
>
{suggestion.label}
</div>
))}
</div>
)}
</div>
);
}
Custom Suggestion Rendering
import React from 'react';
import { QLInput, QLSuggestion } from '@abaktiar/ql-input';
function CustomSuggestionExample() {
const renderSuggestion = (suggestion: QLSuggestion) => (
<div className="flex items-center gap-2">
<span className="font-medium">{suggestion.label}</span>
{suggestion.description && (
<span className="text-sm text-gray-500">{suggestion.description}</span>
)}
</div>
);
// Note: This is a conceptual example - actual implementation may vary
return (
<QLInput
config={config}
placeholder="Custom suggestions..."
/>
);
}
๐งช Testing
Testing with React Testing Library
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { QLInput } from '@abaktiar/ql-input';
test('renders QL input component', () => {
render(<QLInput config={config} placeholder="Test input" />);
const input = screen.getByPlaceholderText('Test input');
expect(input).toBeInTheDocument();
});
test('handles input changes', () => {
const handleChange = jest.fn();
render(
<QLInput
config={config}
onChange={handleChange}
placeholder="Test input"
/>
);
const input = screen.getByPlaceholderText('Test input');
fireEvent.change(input, { target: { value: 'status = "open"' } });
expect(handleChange).toHaveBeenCalled();
});
๐ค Related Packages
- @abaktiar/ql-parser - The underlying parser (included as dependency)
๐ License
MIT ยฉ abaktiar
๐ฏ Field Types & Operators
Supported Field Types
type FieldType = 'text' | 'number' | 'date' | 'datetime' | 'boolean' | 'option' | 'multiselect' | 'user';
Text Fields
{
name: 'title',
displayName: 'Title',
type: 'text',
operators: ['=', '!=', '~', '!~', 'IS EMPTY', 'IS NOT EMPTY']
}
Number Fields
{
name: 'priority',
displayName: 'Priority',
type: 'number',
operators: ['=', '!=', '>', '<', '>=', '<=']
}
Date/DateTime Fields
{
name: 'created',
displayName: 'Created Date',
type: 'date', // or 'datetime'
operators: ['=', '!=', '>', '<', '>=', '<='],
sortable: true
}
Option Fields
{
name: 'status',
displayName: 'Status',
type: 'option',
operators: ['=', '!=', 'IN', 'NOT IN'],
options: [
{ value: 'open', label: 'Open' },
{ value: 'closed', label: 'Closed' }
]
}
Boolean Fields
{
name: 'isPublic',
displayName: 'Is Public',
type: 'boolean',
operators: ['=', '!=']
}
User Fields
{
name: 'assignee',
displayName: 'Assignee',
type: 'user',
operators: ['=', '!=', 'IS EMPTY', 'IS NOT EMPTY'],
asyncValueSuggestions: true
}
Complete Configuration Example
import { QLInputConfig } from '@abaktiar/ql-input';
const fullConfig: QLInputConfig = {
fields: [
{
name: 'title',
displayName: 'Title',
type: 'text',
operators: ['=', '!=', '~', '!~', 'IS EMPTY', 'IS NOT EMPTY'],
description: 'Issue title or summary'
},
{
name: 'status',
displayName: 'Status',
type: 'option',
operators: ['=', '!=', 'IN', 'NOT IN'],
options: [
{ value: 'open', label: 'Open' },
{ value: 'in-progress', label: 'In Progress' },
{ value: 'closed', label: 'Closed' },
{ value: 'pending', label: 'Pending Review' }
]
},
{
name: 'priority',
displayName: 'Priority',
type: 'number',
operators: ['=', '!=', '>', '<', '>=', '<='],
sortable: true,
options: [
{ value: '1', label: 'Low' },
{ value: '2', label: 'Medium' },
{ value: '3', label: 'High' },
{ value: '4', label: 'Critical' }
]
},
{
name: 'assignee',
displayName: 'Assignee',
type: 'user',
operators: ['=', '!=', 'IS EMPTY', 'IS NOT EMPTY'],
asyncValueSuggestions: true
},
{
name: 'created',
displayName: 'Created Date',
type: 'date',
operators: ['=', '!=', '>', '<', '>=', '<='],
sortable: true
},
{
name: 'tags',
displayName: 'Tags',
type: 'multiselect',
operators: ['IN', 'NOT IN'],
options: [
{ value: 'bug', label: 'Bug' },
{ value: 'feature', label: 'Feature' },
{ value: 'enhancement', label: 'Enhancement' }
]
}
],
maxSuggestions: 15,
caseSensitive: false,
allowParentheses: true,
allowOrderBy: true,
allowFunctions: true,
functions: [
{
name: 'currentUser',
displayName: 'currentUser()',
description: 'The currently logged-in user',
},
{
name: 'now',
displayName: 'now()',
description: 'Current date and time',
},
{
name: 'today',
displayName: 'today()',
description: 'Today\'s date',
},
{
name: 'daysAgo',
displayName: 'daysAgo(days)',
description: 'Date N days ago from today',
parameters: [{
name: 'days',
type: 'number',
required: true,
description: 'Number of days'
}]
},
{
name: 'userInRole',
displayName: 'userInRole(role)',
description: 'Users with specific role',
parameters: [{
name: 'role',
type: 'text',
required: true,
description: 'User role name'
}]
}
]
};
๐ Real-World Examples
Issue Tracking System
import React, { useState } from 'react';
import { QLInput, QLQuery } from '@abaktiar/ql-input';
function IssueTracker() {
const [issues, setIssues] = useState([]);
const [loading, setLoading] = useState(false);
const handleSearch = async (query: QLQuery) => {
if (!query.valid) return;
setLoading(true);
try {
const response = await fetch('/api/issues/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: query.raw })
});
const data = await response.json();
setIssues(data.issues);
} catch (error) {
console.error('Search failed:', error);
} finally {
setLoading(false);
}
};
const getUsers = async (field: string, search: string) => {
if (field !== 'assignee') return [];
const response = await fetch(`/api/users?search=${search}`);
const users = await response.json();
return users.map(user => ({
value: user.id,
label: `${user.name} (${user.email})`
}));
};
return (
<div>
<QLInput
config={issueConfig}
placeholder='Search issues... e.g., status = "open" AND assignee = currentUser()'
onExecute={handleSearch}
getAsyncValueSuggestions={getUsers}
/>
{loading && <div>Searching...</div>}
<div>
{issues.map(issue => (
<div key={issue.id} className="issue-card">
<h3>{issue.title}</h3>
<p>Status: {issue.status}</p>
<p>Assignee: {issue.assignee}</p>
</div>
))}
</div>
</div>
);
}
E-commerce Product Search
import React from 'react';
import { QLInput } from '@abaktiar/ql-input';
const productConfig = {
fields: [
{
name: 'name',
displayName: 'Product Name',
type: 'text',
operators: ['~', '!~', 'IS EMPTY', 'IS NOT EMPTY']
},
{
name: 'category',
displayName: 'Category',
type: 'option',
operators: ['=', '!=', 'IN', 'NOT IN'],
options: [
{ value: 'electronics', label: 'Electronics' },
{ value: 'clothing', label: 'Clothing' },
{ value: 'books', label: 'Books' }
]
},
{
name: 'price',
displayName: 'Price',
type: 'number',
operators: ['=', '>', '<', '>=', '<='],
sortable: true
},
{
name: 'inStock',
displayName: 'In Stock',
type: 'boolean',
operators: ['=', '!=']
}
],
allowOrderBy: true,
allowParentheses: true
};
function ProductSearch() {
const handleProductSearch = (value: string, query: QLQuery) => {
console.log('Searching products:', query);
// Implement product search logic
};
return (
<QLInput
config={productConfig}
placeholder='Search products... e.g., category = "electronics" AND price < 500 ORDER BY price ASC'
onChange={handleProductSearch}
/>
);
}
๐จ Icon Customization
Icon Visibility Control
Control which icons are displayed in the input:
// Hide search icon for minimal design
<QLInput
config={config}
showSearchIcon={false}
placeholder="Enter query..."
/>
// Hide clear icon to prevent accidental clearing
<QLInput
config={config}
showClearIcon={false}
placeholder="Enter query..."
/>
// Hide both icons for ultra-minimal design
<QLInput
config={config}
showSearchIcon={false}
showClearIcon={false}
placeholder="Enter query..."
/>
// Default behavior (both icons shown)
<QLInput
config={config}
showSearchIcon={true} // Default
showClearIcon={true} // Default
placeholder="Enter query..."
/>
Use Cases for Icon Control
- Minimal Design: Hide search icon when the context is clear
- Prevent Accidental Clearing: Hide clear icon in critical forms
- Mobile Optimization: Reduce visual clutter on small screens
- Custom Branding: Match your application's design system
๐จ Theming Examples
Custom Theme
/* Custom theme variables */
.my-custom-theme {
--ql-input-border: #e2e8f0;
--ql-input-background: #ffffff;
--ql-input-foreground: #1a202c;
--ql-input-muted: #f7fafc;
--ql-input-muted-foreground: #718096;
--ql-input-ring: #3182ce;
}
.my-custom-theme .ql-input {
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.my-custom-theme .ql-suggestion-item {
padding: 12px;
border-radius: 8px;
}
.my-custom-theme .ql-suggestion-item:hover {
background-color: #ebf8ff;
}
Material Design Theme
.material-theme {
--ql-input-border: #e0e0e0;
--ql-input-background: #ffffff;
--ql-input-foreground: #212121;
--ql-input-muted: #f5f5f5;
--ql-input-muted-foreground: #757575;
--ql-input-ring: #2196f3;
}
.material-theme .ql-input {
border-radius: 4px;
border-bottom: 2px solid var(--ql-input-ring);
border-top: none;
border-left: none;
border-right: none;
background: transparent;
}
๐ง Performance Optimization
Debounced Async Suggestions
import React, { useMemo } from 'react';
import { QLInput, useDebounce } from '@abaktiar/ql-input';
function OptimizedComponent() {
const debouncedGetSuggestions = useMemo(
() => useDebounce(async (field: string, search: string) => {
// Expensive API call
const response = await fetch(`/api/suggestions/${field}?q=${search}`);
return response.json();
}, 300),
[]
);
return (
<QLInput
config={config}
getAsyncValueSuggestions={debouncedGetSuggestions}
/>
);
}
Memoized Configuration
import React, { useMemo } from 'react';
import { QLInput } from '@abaktiar/ql-input';
function MemoizedConfig({ fields, options }) {
const config = useMemo(() => ({
fields: fields.map(field => ({
...field,
options: options[field.name] || []
})),
maxSuggestions: 10,
allowParentheses: true,
allowOrderBy: true
}), [fields, options]);
return <QLInput config={config} />;
}
๐งช Testing Examples
Complete Test Suite
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QLInput } from '@abaktiar/ql-input';
const testConfig = {
fields: [
{
name: 'status',
displayName: 'Status',
type: 'option',
operators: ['=', '!='],
options: [
{ value: 'open', label: 'Open' },
{ value: 'closed', label: 'Closed' }
]
}
]
};
describe('QLInput Component', () => {
test('shows suggestions when typing', async () => {
render(<QLInput config={testConfig} />);
const input = screen.getByRole('textbox');
await userEvent.type(input, 'sta');
await waitFor(() => {
expect(screen.getByText('Status')).toBeInTheDocument();
});
});
test('executes query on Enter', async () => {
const handleExecute = jest.fn();
render(<QLInput config={testConfig} onExecute={handleExecute} />);
const input = screen.getByRole('textbox');
await userEvent.type(input, 'status = "open"');
await userEvent.keyboard('{Enter}');
expect(handleExecute).toHaveBeenCalledWith(
expect.objectContaining({
raw: 'status = "open"',
valid: true
})
);
});
test('handles async suggestions', async () => {
const getAsyncSuggestions = jest.fn().mockResolvedValue([
{ value: 'user1', label: 'User 1' }
]);
render(
<QLInput
config={testConfig}
getAsyncValueSuggestions={getAsyncSuggestions}
/>
);
const input = screen.getByRole('textbox');
await userEvent.type(input, 'assignee = "u');
await waitFor(() => {
expect(getAsyncSuggestions).toHaveBeenCalledWith('assignee', 'u');
});
});
});
๐ Issues & Support
Please report issues on GitHub Issues.