@papernote/ui 1.12.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/AdminModal.d.ts.map +1 -1
- package/dist/components/FilterBar.d.ts +1 -1
- package/dist/components/FilterBar.d.ts.map +1 -1
- package/dist/components/FilterPills.d.ts +14 -0
- package/dist/components/FilterPills.d.ts.map +1 -0
- package/dist/components/LetterNav.d.ts +8 -0
- package/dist/components/LetterNav.d.ts.map +1 -0
- package/dist/components/Pagination.d.ts +11 -1
- package/dist/components/Pagination.d.ts.map +1 -1
- package/dist/components/Sidebar.d.ts.map +1 -1
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/index.d.ts +36 -4
- package/dist/index.esm.js +305 -226
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +305 -224
- package/dist/index.js.map +1 -1
- package/dist/styles.css +8 -0
- package/package.json +1 -1
- package/src/components/AdminModal.tsx +1 -0
- package/src/components/FilterBar.tsx +116 -3
- package/src/components/FilterPills.tsx +58 -0
- package/src/components/LetterNav.tsx +67 -0
- package/src/components/Pagination.tsx +49 -1
- package/src/components/Sidebar.tsx +19 -1
- package/src/components/index.ts +6 -0
package/dist/styles.css
CHANGED
|
@@ -4739,6 +4739,10 @@ input:checked + .slider:before{
|
|
|
4739
4739
|
letter-spacing: 0.05em;
|
|
4740
4740
|
}
|
|
4741
4741
|
|
|
4742
|
+
.tracking-widest{
|
|
4743
|
+
letter-spacing: 0.1em;
|
|
4744
|
+
}
|
|
4745
|
+
|
|
4742
4746
|
.text-accent-200{
|
|
4743
4747
|
--tw-text-opacity: 1;
|
|
4744
4748
|
color: rgb(232 231 224 / var(--tw-text-opacity, 1));
|
|
@@ -5066,6 +5070,10 @@ input:checked + .slider:before{
|
|
|
5066
5070
|
text-decoration-line: line-through;
|
|
5067
5071
|
}
|
|
5068
5072
|
|
|
5073
|
+
.underline-offset-2{
|
|
5074
|
+
text-underline-offset: 2px;
|
|
5075
|
+
}
|
|
5076
|
+
|
|
5069
5077
|
.placeholder-ink-400::-moz-placeholder{
|
|
5070
5078
|
--tw-placeholder-opacity: 1;
|
|
5071
5079
|
color: rgb(168 162 158 / var(--tw-placeholder-opacity, 1));
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { X } from 'lucide-react';
|
|
1
|
+
import { X, Search } from 'lucide-react';
|
|
2
2
|
import Input from './Input';
|
|
3
3
|
import Select, { type SelectOption } from './Select';
|
|
4
4
|
import Button from './Button';
|
|
@@ -6,7 +6,7 @@ import Button from './Button';
|
|
|
6
6
|
export interface FilterConfig {
|
|
7
7
|
key: string;
|
|
8
8
|
label: string;
|
|
9
|
-
type: 'text' | 'select' | 'date' | 'number' | 'boolean';
|
|
9
|
+
type: 'text' | 'search' | 'select' | 'date' | 'number' | 'boolean' | 'dateRange' | 'toggle' | 'multiSelect';
|
|
10
10
|
placeholder?: string;
|
|
11
11
|
options?: Array<{ label: string; value: unknown }>;
|
|
12
12
|
}
|
|
@@ -42,7 +42,15 @@ export default function FilterBar({
|
|
|
42
42
|
// Default clear: set all values to null/empty
|
|
43
43
|
const clearedValues: Record<string, unknown> = {};
|
|
44
44
|
filters.forEach(filter => {
|
|
45
|
-
|
|
45
|
+
if (filter.type === 'text' || filter.type === 'search') {
|
|
46
|
+
clearedValues[filter.key] = '';
|
|
47
|
+
} else if (filter.type === 'dateRange') {
|
|
48
|
+
clearedValues[filter.key] = { from: undefined, to: undefined };
|
|
49
|
+
} else if (filter.type === 'multiSelect') {
|
|
50
|
+
clearedValues[filter.key] = [];
|
|
51
|
+
} else {
|
|
52
|
+
clearedValues[filter.key] = null;
|
|
53
|
+
}
|
|
46
54
|
});
|
|
47
55
|
onChange(clearedValues);
|
|
48
56
|
}
|
|
@@ -127,6 +135,111 @@ export default function FilterBar({
|
|
|
127
135
|
);
|
|
128
136
|
}
|
|
129
137
|
|
|
138
|
+
case 'search':
|
|
139
|
+
return (
|
|
140
|
+
<div className="relative">
|
|
141
|
+
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
142
|
+
<Search className="h-4 w-4 text-ink-400" />
|
|
143
|
+
</div>
|
|
144
|
+
<input
|
|
145
|
+
type="text"
|
|
146
|
+
placeholder={filter.placeholder || `Search ${filter.label}...`}
|
|
147
|
+
value={(value as string) || ''}
|
|
148
|
+
onChange={(e) => handleFilterChange(filter.key, e.target.value)}
|
|
149
|
+
className="input pl-9"
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
case 'dateRange': {
|
|
155
|
+
const rangeValue = (value as { from?: string; to?: string }) || {};
|
|
156
|
+
return (
|
|
157
|
+
<div className="flex items-center gap-2">
|
|
158
|
+
<input
|
|
159
|
+
type="date"
|
|
160
|
+
value={rangeValue.from || ''}
|
|
161
|
+
onChange={(e) =>
|
|
162
|
+
handleFilterChange(filter.key, { ...rangeValue, from: e.target.value || undefined })
|
|
163
|
+
}
|
|
164
|
+
className="input text-sm"
|
|
165
|
+
aria-label={`${filter.label} from`}
|
|
166
|
+
/>
|
|
167
|
+
<span className="text-ink-400 text-xs">to</span>
|
|
168
|
+
<input
|
|
169
|
+
type="date"
|
|
170
|
+
value={rangeValue.to || ''}
|
|
171
|
+
onChange={(e) =>
|
|
172
|
+
handleFilterChange(filter.key, { ...rangeValue, to: e.target.value || undefined })
|
|
173
|
+
}
|
|
174
|
+
className="input text-sm"
|
|
175
|
+
aria-label={`${filter.label} to`}
|
|
176
|
+
/>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
case 'toggle': {
|
|
182
|
+
const toggleOptions: SelectOption[] = [
|
|
183
|
+
{ value: '', label: 'All' },
|
|
184
|
+
{ value: 'true', label: 'Yes' },
|
|
185
|
+
{ value: 'false', label: 'No' },
|
|
186
|
+
];
|
|
187
|
+
const currentVal = value === null || value === undefined ? '' : String(value);
|
|
188
|
+
return (
|
|
189
|
+
<div className="flex rounded-lg border border-paper-300 overflow-hidden" role="group">
|
|
190
|
+
{toggleOptions.map((opt) => (
|
|
191
|
+
<button
|
|
192
|
+
key={opt.value}
|
|
193
|
+
type="button"
|
|
194
|
+
onClick={() => handleFilterChange(filter.key, opt.value === '' ? null : opt.value === 'true')}
|
|
195
|
+
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
196
|
+
currentVal === opt.value
|
|
197
|
+
? 'bg-accent-500 text-white'
|
|
198
|
+
: 'bg-white text-ink-600 hover:bg-paper-50'
|
|
199
|
+
} ${opt.value !== '' ? 'border-l border-paper-300' : ''}`}
|
|
200
|
+
>
|
|
201
|
+
{opt.label}
|
|
202
|
+
</button>
|
|
203
|
+
))}
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
case 'multiSelect': {
|
|
209
|
+
const selectedValues = Array.isArray(value) ? (value as string[]) : [];
|
|
210
|
+
const msOptions = filter.options || [];
|
|
211
|
+
return (
|
|
212
|
+
<div className="relative">
|
|
213
|
+
<Select
|
|
214
|
+
options={[{ value: '', label: `All ${filter.label}` }, ...msOptions.map(o => ({ value: String(o.value), label: o.label }))]}
|
|
215
|
+
value=""
|
|
216
|
+
onChange={(newValue) => {
|
|
217
|
+
if (!newValue) {
|
|
218
|
+
handleFilterChange(filter.key, []);
|
|
219
|
+
} else if (!selectedValues.includes(newValue)) {
|
|
220
|
+
handleFilterChange(filter.key, [...selectedValues, newValue]);
|
|
221
|
+
}
|
|
222
|
+
}}
|
|
223
|
+
/>
|
|
224
|
+
{selectedValues.length > 0 && (
|
|
225
|
+
<div className="flex flex-wrap gap-1 mt-1">
|
|
226
|
+
{selectedValues.map((sv) => {
|
|
227
|
+
const opt = msOptions.find(o => String(o.value) === sv);
|
|
228
|
+
return (
|
|
229
|
+
<span key={sv} className="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-100 text-accent-700 rounded-full">
|
|
230
|
+
{opt?.label || sv}
|
|
231
|
+
<button type="button" onClick={() => handleFilterChange(filter.key, selectedValues.filter(v => v !== sv))} className="hover:text-accent-900">
|
|
232
|
+
<X className="h-3 w-3" />
|
|
233
|
+
</button>
|
|
234
|
+
</span>
|
|
235
|
+
);
|
|
236
|
+
})}
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
130
243
|
default:
|
|
131
244
|
return null;
|
|
132
245
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import Chip from './Chip';
|
|
2
|
+
import { Filter } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
export interface FilterPill {
|
|
5
|
+
key: string;
|
|
6
|
+
label: string;
|
|
7
|
+
displayValue: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface FilterPillsProps {
|
|
11
|
+
pills: FilterPill[];
|
|
12
|
+
onRemove: (key: string) => void;
|
|
13
|
+
onClearAll: () => void;
|
|
14
|
+
totalCount?: number;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function FilterPills({
|
|
19
|
+
pills,
|
|
20
|
+
onRemove,
|
|
21
|
+
onClearAll,
|
|
22
|
+
totalCount,
|
|
23
|
+
className = '',
|
|
24
|
+
}: FilterPillsProps) {
|
|
25
|
+
if (pills.length === 0) return null;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className={`flex items-center gap-2 px-4 py-2 border-b border-paper-200 bg-paper-50 ${className}`}>
|
|
29
|
+
<Filter className="h-3.5 w-3.5 text-ink-400 shrink-0" />
|
|
30
|
+
<div className="flex items-center gap-1.5 flex-wrap flex-1">
|
|
31
|
+
{pills.map((pill) => (
|
|
32
|
+
<Chip
|
|
33
|
+
key={pill.key}
|
|
34
|
+
size="sm"
|
|
35
|
+
variant="primary"
|
|
36
|
+
onClose={() => onRemove(pill.key)}
|
|
37
|
+
>
|
|
38
|
+
{pill.label}: {pill.displayValue}
|
|
39
|
+
</Chip>
|
|
40
|
+
))}
|
|
41
|
+
{pills.length >= 2 && (
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
onClick={onClearAll}
|
|
45
|
+
className="text-xs text-ink-500 hover:text-ink-700 underline underline-offset-2 ml-1"
|
|
46
|
+
>
|
|
47
|
+
Clear all
|
|
48
|
+
</button>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
{totalCount !== undefined && (
|
|
52
|
+
<span className="text-xs text-ink-500 shrink-0 tabular-nums">
|
|
53
|
+
{totalCount.toLocaleString()} {totalCount === 1 ? 'record' : 'records'}
|
|
54
|
+
</span>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
|
2
|
+
|
|
3
|
+
export interface LetterNavProps {
|
|
4
|
+
activeLetter: string | null;
|
|
5
|
+
onChange: (letter: string | null) => void;
|
|
6
|
+
availableLetters?: string[];
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function LetterNav({
|
|
11
|
+
activeLetter,
|
|
12
|
+
onChange,
|
|
13
|
+
availableLetters,
|
|
14
|
+
className = '',
|
|
15
|
+
}: LetterNavProps) {
|
|
16
|
+
const hasAvailability = availableLetters && availableLetters.length > 0;
|
|
17
|
+
const availableSet = hasAvailability
|
|
18
|
+
? new Set(availableLetters!.map((l) => l.toUpperCase()))
|
|
19
|
+
: null;
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className={`flex items-center gap-0.5 px-4 py-1.5 border-b border-paper-200 bg-white overflow-x-auto ${className}`}>
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
onClick={() => onChange(null)}
|
|
26
|
+
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
|
|
27
|
+
activeLetter === null
|
|
28
|
+
? 'bg-accent-500 text-white'
|
|
29
|
+
: 'text-ink-600 hover:bg-paper-100'
|
|
30
|
+
}`}
|
|
31
|
+
>
|
|
32
|
+
All
|
|
33
|
+
</button>
|
|
34
|
+
{LETTERS.map((letter) => {
|
|
35
|
+
const isActive = activeLetter === letter;
|
|
36
|
+
const isAvailable = !availableSet || availableSet.has(letter);
|
|
37
|
+
return (
|
|
38
|
+
<button
|
|
39
|
+
key={letter}
|
|
40
|
+
type="button"
|
|
41
|
+
onClick={() => onChange(isActive ? null : letter)}
|
|
42
|
+
className={`w-7 h-7 text-xs font-medium rounded transition-colors ${
|
|
43
|
+
isActive
|
|
44
|
+
? 'bg-accent-500 text-white'
|
|
45
|
+
: isAvailable
|
|
46
|
+
? 'text-ink-600 hover:bg-paper-100'
|
|
47
|
+
: 'text-ink-300'
|
|
48
|
+
}`}
|
|
49
|
+
>
|
|
50
|
+
{letter}
|
|
51
|
+
</button>
|
|
52
|
+
);
|
|
53
|
+
})}
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={() => onChange(activeLetter === '#' ? null : '#')}
|
|
57
|
+
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
|
|
58
|
+
activeLetter === '#'
|
|
59
|
+
? 'bg-accent-500 text-white'
|
|
60
|
+
: 'text-ink-600 hover:bg-paper-100'
|
|
61
|
+
}`}
|
|
62
|
+
>
|
|
63
|
+
#
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -10,6 +10,16 @@ export interface PaginationProps {
|
|
|
10
10
|
maxPageNumbers?: number;
|
|
11
11
|
/** Show page jump input field */
|
|
12
12
|
showPageJump?: boolean;
|
|
13
|
+
/** Total number of items across all pages */
|
|
14
|
+
totalItems?: number;
|
|
15
|
+
/** Current page size */
|
|
16
|
+
pageSize?: number;
|
|
17
|
+
/** Available page size options */
|
|
18
|
+
pageSizeOptions?: number[];
|
|
19
|
+
/** Callback when page size changes */
|
|
20
|
+
onPageSizeChange?: (size: number) => void;
|
|
21
|
+
/** Show "Showing X-Y of Z records" text */
|
|
22
|
+
showRecordCount?: boolean;
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
export default function Pagination({
|
|
@@ -19,6 +29,11 @@ export default function Pagination({
|
|
|
19
29
|
showPageNumbers = true,
|
|
20
30
|
maxPageNumbers = 5,
|
|
21
31
|
showPageJump = false,
|
|
32
|
+
totalItems,
|
|
33
|
+
pageSize,
|
|
34
|
+
pageSizeOptions,
|
|
35
|
+
onPageSizeChange,
|
|
36
|
+
showRecordCount = false,
|
|
22
37
|
}: PaginationProps) {
|
|
23
38
|
const [jumpValue, setJumpValue] = useState('');
|
|
24
39
|
const getPageNumbers = () => {
|
|
@@ -67,8 +82,23 @@ export default function Pagination({
|
|
|
67
82
|
}
|
|
68
83
|
};
|
|
69
84
|
|
|
85
|
+
const showLeftSection = showRecordCount && totalItems !== undefined && pageSize;
|
|
86
|
+
const showRightSection = onPageSizeChange && pageSizeOptions && pageSizeOptions.length > 0;
|
|
87
|
+
|
|
88
|
+
const rangeStart = totalItems ? (currentPage - 1) * (pageSize || 0) + 1 : 0;
|
|
89
|
+
const rangeEnd = totalItems ? Math.min(currentPage * (pageSize || 0), totalItems) : 0;
|
|
90
|
+
|
|
70
91
|
return (
|
|
71
|
-
<nav className=
|
|
92
|
+
<nav className={`flex items-center gap-2 ${showLeftSection || showRightSection ? 'justify-between' : 'justify-center'}`} aria-label="Pagination">
|
|
93
|
+
{/* Record Count (left) */}
|
|
94
|
+
{showLeftSection ? (
|
|
95
|
+
<span className="text-sm text-ink-500 tabular-nums shrink-0">
|
|
96
|
+
Showing {rangeStart.toLocaleString()}–{rangeEnd.toLocaleString()} of {totalItems!.toLocaleString()}
|
|
97
|
+
</span>
|
|
98
|
+
) : showRightSection ? <div /> : null}
|
|
99
|
+
|
|
100
|
+
{/* Center: nav buttons */}
|
|
101
|
+
<div className="flex items-center gap-2">
|
|
72
102
|
{/* Previous Button */}
|
|
73
103
|
<button
|
|
74
104
|
onClick={() => onPageChange(currentPage - 1)}
|
|
@@ -148,6 +178,24 @@ export default function Pagination({
|
|
|
148
178
|
</button>
|
|
149
179
|
</form>
|
|
150
180
|
)}
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
{/* Page Size Selector (right) */}
|
|
184
|
+
{showRightSection ? (
|
|
185
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
186
|
+
<span className="text-sm text-ink-500 hidden sm:inline">Per page:</span>
|
|
187
|
+
<select
|
|
188
|
+
value={pageSize || pageSizeOptions![0]}
|
|
189
|
+
onChange={(e) => onPageSizeChange!(Number(e.target.value))}
|
|
190
|
+
className="px-2 py-1.5 text-sm border border-paper-300 rounded-lg bg-white text-ink-700 focus:outline-none focus:ring-2 focus:ring-accent-400 focus:border-accent-400 cursor-pointer"
|
|
191
|
+
aria-label="Items per page"
|
|
192
|
+
>
|
|
193
|
+
{pageSizeOptions!.map((size) => (
|
|
194
|
+
<option key={size} value={size}>{size}</option>
|
|
195
|
+
))}
|
|
196
|
+
</select>
|
|
197
|
+
</div>
|
|
198
|
+
) : showLeftSection ? <div /> : null}
|
|
151
199
|
</nav>
|
|
152
200
|
);
|
|
153
201
|
}
|
|
@@ -331,8 +331,26 @@ export default function Sidebar({
|
|
|
331
331
|
{/* Navigation */}
|
|
332
332
|
<nav className="flex-1 px-3 py-2 space-y-1 overflow-y-auto">
|
|
333
333
|
{items.map((item) => {
|
|
334
|
-
// Render separator
|
|
334
|
+
// Render separator or section header
|
|
335
335
|
if (item.separator) {
|
|
336
|
+
// Section header: separator with a label
|
|
337
|
+
if (item.label) {
|
|
338
|
+
return (
|
|
339
|
+
<div
|
|
340
|
+
key={item.id}
|
|
341
|
+
className="mt-6 mb-2 px-3"
|
|
342
|
+
data-testid={item.dataAttributes?.['data-testid'] || `sidebar-section-${item.id}`}
|
|
343
|
+
{...item.dataAttributes}
|
|
344
|
+
>
|
|
345
|
+
<div className="border-t border-paper-300 pt-3">
|
|
346
|
+
<span className="text-[10px] font-semibold uppercase tracking-widest text-ink-400">
|
|
347
|
+
{item.label}
|
|
348
|
+
</span>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
// Plain separator: just a line
|
|
336
354
|
return (
|
|
337
355
|
<div
|
|
338
356
|
key={item.id}
|
package/src/components/index.ts
CHANGED
|
@@ -54,6 +54,12 @@ export type { FormControlProps } from './FormControl';
|
|
|
54
54
|
export { default as FilterBar } from './FilterBar';
|
|
55
55
|
export type { FilterBarProps, FilterConfig } from './FilterBar';
|
|
56
56
|
|
|
57
|
+
export { default as FilterPills } from './FilterPills';
|
|
58
|
+
export type { FilterPillsProps, FilterPill } from './FilterPills';
|
|
59
|
+
|
|
60
|
+
export { default as LetterNav } from './LetterNav';
|
|
61
|
+
export type { LetterNavProps } from './LetterNav';
|
|
62
|
+
|
|
57
63
|
export { default as StatCard } from './StatCard';
|
|
58
64
|
export type { StatCardProps } from './StatCard';
|
|
59
65
|
export { default as StatsGrid, StatItem } from './StatsGrid';
|