UNPKG

@abaktiar/ql-input

Version:

React QL input component with intelligent autocomplete, parameterized functions, and query validation. Framework-independent with zero external dependencies.

1,127 lines (950 loc) โ€ข 26.5 kB
# @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](https://ql-input.netlify.app/)** - Try the component in action! [![npm version](https://badge.fury.io/js/@abaktiar%2Fql-input.svg)](https://badge.fury.io/js/@abaktiar%2Fql-input) [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/) [![React](https://img.shields.io/badge/React-18+-blue.svg)](https://reactjs.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) ## ๐ŸŽ‰ 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 like `daysAgo(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 ```bash npm install @abaktiar/ql-input ``` ```bash yarn add @abaktiar/ql-input ``` ```bash pnpm add @abaktiar/ql-input ``` ### Peer Dependencies Make sure you have the required peer dependencies installed: ```bash npm install react react-dom ``` ## ๐Ÿš€ Quick Start ### Basic Setup ```tsx 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: ```css @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 ```typescript 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 ```typescript interface QLInputConfig { fields: QLField[]; maxSuggestions?: number; caseSensitive?: boolean; allowParentheses?: boolean; allowOrderBy?: boolean; allowFunctions?: boolean; functions?: QLFunction[]; } ``` ## ๐Ÿ’ก Usage Examples ### Controlled Component ```tsx 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 ```tsx 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 ```tsx 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(&quot;admin&quot;) AND created >= daysAgo(30)" /> ); } ``` ## ๐ŸŽฏ Query Examples The component supports powerful QL queries with parameterized functions: ### Basic Function Queries ```sql -- 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 ```sql -- 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 ```sql -- 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 ```tsx 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 ```tsx 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: ```css :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: ```css /* Custom theme */ .my-custom-theme { --ql-input-border: #3b82f6; --ql-input-ring: #1d4ed8; --ql-input-background: #f8fafc; --ql-input-radius: 0.75rem; } ``` ```tsx 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 ```tsx 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 ```tsx 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 ```tsx 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](https://www.npmjs.com/package/@abaktiar/ql-parser)** - The underlying parser (included as dependency) ## ๐Ÿ“„ License MIT ยฉ [abaktiar](https://github.com/abaktiar) ## ๐ŸŽฏ Field Types & Operators ### Supported Field Types ```typescript type FieldType = 'text' | 'number' | 'date' | 'datetime' | 'boolean' | 'option' | 'multiselect' | 'user'; ``` #### Text Fields ```typescript { name: 'title', displayName: 'Title', type: 'text', operators: ['=', '!=', '~', '!~', 'IS EMPTY', 'IS NOT EMPTY'] } ``` #### Number Fields ```typescript { name: 'priority', displayName: 'Priority', type: 'number', operators: ['=', '!=', '>', '<', '>=', '<='] } ``` #### Date/DateTime Fields ```typescript { name: 'created', displayName: 'Created Date', type: 'date', // or 'datetime' operators: ['=', '!=', '>', '<', '>=', '<='], sortable: true } ``` #### Option Fields ```typescript { name: 'status', displayName: 'Status', type: 'option', operators: ['=', '!=', 'IN', 'NOT IN'], options: [ { value: 'open', label: 'Open' }, { value: 'closed', label: 'Closed' } ] } ``` #### Boolean Fields ```typescript { name: 'isPublic', displayName: 'Is Public', type: 'boolean', operators: ['=', '!='] } ``` #### User Fields ```typescript { name: 'assignee', displayName: 'Assignee', type: 'user', operators: ['=', '!=', 'IS EMPTY', 'IS NOT EMPTY'], asyncValueSuggestions: true } ``` ### Complete Configuration Example ```typescript 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 ```tsx 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 ```tsx 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: ```tsx // 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 ```css /* 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 ```css .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 ```tsx 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 ```tsx 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 ```tsx 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](https://github.com/abaktiar/ql-input/issues).