@jinaan/uikit 0.1.0 → 0.1.2

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/input.d.ts CHANGED
@@ -7,6 +7,8 @@ export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>,
7
7
  leadingIcon?: ReactNode;
8
8
  /** Optional icon rendered inside the field, after the text. */
9
9
  trailingIcon?: ReactNode;
10
+ /** Shows a real clear button when the field has a value. */
11
+ onClear?: () => void;
10
12
  /** Error styling; also pairs with `helperText` for the message. */
11
13
  error?: boolean;
12
14
  /** Helper or error message shown below the field (wired to aria-describedby). */
@@ -1 +1 @@
1
- {"version":3,"file":"input.d.ts","sourceRoot":"","sources":["../src/input.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,mBAAmB,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAIpF,KAAK,SAAS,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAqC3C,MAAM,WAAW,UACf,SAAQ,IAAI,CAAC,mBAAmB,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC3D,+CAA+C;IAC/C,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,gEAAgE;IAChE,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB,+DAA+D;IAC/D,YAAY,CAAC,EAAE,SAAS,CAAC;IACzB,mEAAmE;IACnE,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,iFAAiF;IACjF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,eAAO,MAAM,KAAK,yGAgHjB,CAAC"}
1
+ {"version":3,"file":"input.d.ts","sourceRoot":"","sources":["../src/input.tsx"],"names":[],"mappings":"AACA,OAAO,EAAqB,KAAK,mBAAmB,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAIpF,KAAK,SAAS,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAqC3C,MAAM,WAAW,UACf,SAAQ,IAAI,CAAC,mBAAmB,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC3D,+CAA+C;IAC/C,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,gEAAgE;IAChE,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB,+DAA+D;IAC/D,YAAY,CAAC,EAAE,SAAS,CAAC;IACzB,4DAA4D;IAC5D,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,mEAAmE;IACnE,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,iFAAiF;IACjF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,eAAO,MAAM,KAAK,yGAqIjB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jinaan/uikit",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Reusable React and Tailwind UI components for Jinaan projects.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -3,6 +3,8 @@ import {
3
3
  CaretUp,
4
4
  CaretUpDown,
5
5
  Columns as ColumnsIcon,
6
+ FunnelSimple,
7
+ Lock,
6
8
  MagnifyingGlass,
7
9
  WarningCircle,
8
10
  X,
@@ -17,6 +19,7 @@ import {
17
19
  DropdownMenu,
18
20
  DropdownMenuCheckboxItem,
19
21
  DropdownMenuContent,
22
+ DropdownMenuItem,
20
23
  DropdownMenuLabel,
21
24
  DropdownMenuSeparator,
22
25
  DropdownMenuTrigger,
@@ -49,6 +52,23 @@ export interface Column<T> {
49
52
  className?: string;
50
53
  /** Column width for the fixed layout (e.g. "18rem", "20%"). Defaults to auto. */
51
54
  width?: string;
55
+ /** Required columns stay visible and cannot be hidden. */
56
+ required?: boolean;
57
+ }
58
+
59
+ export interface DataTableFilterOption {
60
+ value: string;
61
+ label: string;
62
+ }
63
+
64
+ export interface DataTableFilter<T> {
65
+ id: string;
66
+ label: string;
67
+ options: DataTableFilterOption[];
68
+ value: string[];
69
+ onChange: (value: string[]) => void;
70
+ /** Return true when the row matches the selected filter values. */
71
+ match: (row: T, values: string[]) => boolean;
52
72
  }
53
73
 
54
74
  export interface BulkAction<T> {
@@ -90,6 +110,7 @@ interface DataTableProps<T> {
90
110
  placeholder?: string;
91
111
  keys: (row: T) => string;
92
112
  };
113
+ filters?: DataTableFilter<T>[];
93
114
  toolbarStart?: ReactNode;
94
115
  toolbarEnd?: ReactNode;
95
116
 
@@ -124,6 +145,9 @@ interface DataTableProps<T> {
124
145
  const HEADER_CELL =
125
146
  "h-[var(--table-header-h)] whitespace-nowrap px-[var(--table-pad-x)] text-left align-middle text-[0.8125rem] font-medium";
126
147
 
148
+ const TOOLBAR_BUTTON =
149
+ "rounded-[6px] bg-surface text-fg shadow-xs ring-1 ring-line hover:bg-elevated hover:text-fg active:bg-elevated";
150
+
127
151
  function initials(name: string): string {
128
152
  return name
129
153
  .split(/\s+/)
@@ -164,6 +188,7 @@ export function DataTable<T>({
164
188
  error,
165
189
  empty,
166
190
  search,
191
+ filters,
167
192
  toolbarStart,
168
193
  toolbarEnd,
169
194
  tabs,
@@ -184,7 +209,9 @@ export function DataTable<T>({
184
209
  className,
185
210
  }: DataTableProps<T>) {
186
211
  const [hidden, setHidden] = useState<Record<string, boolean>>(() =>
187
- Object.fromEntries(columns.filter((c) => c.defaultHidden).map((c) => [c.id, true])),
212
+ Object.fromEntries(
213
+ columns.filter((c) => c.defaultHidden && !c.required).map((c) => [c.id, true]),
214
+ ),
188
215
  );
189
216
  const [sort, setSort] = useState<{ id: string; dir: "asc" | "desc" } | null>(
190
217
  defaultSort ?? null,
@@ -192,14 +219,26 @@ export function DataTable<T>({
192
219
  const [page, setPage] = useState(0);
193
220
  const [pageSize, setPageSize] = useState(initialPageSize);
194
221
  const [selected, setSelected] = useState<Set<string>>(new Set());
222
+ const [activeFilterIds, setActiveFilterIds] = useState<string[]>([]);
195
223
 
196
224
  const visibleColumns = columns.filter((c) => !hidden[c.id]);
225
+ const hiddenColumns = columns.filter((c) => hidden[c.id] && !c.required);
226
+ const activeFilters = (filters ?? []).filter((f) => activeFilterIds.includes(f.id));
227
+ const filterSignature = (filters ?? [])
228
+ .map((f) => `${f.id}:${f.value.join(",")}`)
229
+ .join("|");
197
230
 
198
231
  const filtered = useMemo(() => {
199
- if (!search || !search.value.trim()) return rows;
232
+ let next = rows;
233
+ for (const filter of filters ?? []) {
234
+ if (filter.value.length > 0) {
235
+ next = next.filter((row) => filter.match(row, filter.value));
236
+ }
237
+ }
238
+ if (!search || !search.value.trim()) return next;
200
239
  const q = search.value.trim().toLowerCase();
201
- return rows.filter((r) => search.keys(r).toLowerCase().includes(q));
202
- }, [rows, search]);
240
+ return next.filter((r) => search.keys(r).toLowerCase().includes(q));
241
+ }, [rows, search, filters]);
203
242
 
204
243
  const sorted = useMemo(() => {
205
244
  if (!sort) return filtered;
@@ -221,7 +260,7 @@ export function DataTable<T>({
221
260
 
222
261
  useEffect(() => {
223
262
  setPage(0);
224
- }, [search?.value, pageSize, sort?.id, sort?.dir, activeTab]);
263
+ }, [search?.value, pageSize, sort?.id, sort?.dir, activeTab, filterSignature]);
225
264
 
226
265
  useEffect(() => {
227
266
  if (page > pageCount - 1) setPage(pageCount - 1);
@@ -263,7 +302,7 @@ export function DataTable<T>({
263
302
  });
264
303
  };
265
304
 
266
- const hasToolbar = Boolean(search || toolbarStart || toolbarEnd);
305
+ const hasToolbar = Boolean(search || filters?.length || toolbarStart || toolbarEnd);
267
306
 
268
307
  // Fixed layout keeps header and body columns aligned across the two tables.
269
308
  // When no column declares a width, give the first column more room and split
@@ -317,49 +356,170 @@ export function DataTable<T>({
317
356
  <div className="flex flex-wrap items-center gap-2">
318
357
  {search && (
319
358
  <Input
320
- size="md"
359
+ size="sm"
321
360
  type="search"
322
361
  aria-label={search.placeholder ?? "Search"}
323
- placeholder={search.placeholder ?? "Search…"}
362
+ placeholder={search.placeholder ?? "Search..."}
324
363
  value={search.value}
325
364
  onChange={(e) => search.onChange(e.target.value)}
365
+ onClear={() => search.onChange("")}
326
366
  leadingIcon={<MagnifyingGlass />}
327
- containerClassName="w-full sm:w-72"
367
+ containerClassName="w-full sm:w-[280px]"
368
+ className="rounded-[6px] border-0 bg-surface shadow-xs ring-1 ring-line"
328
369
  />
329
370
  )}
330
371
  {toolbarStart}
331
- <div className="ml-auto flex items-center gap-2">
372
+ <DropdownMenu>
373
+ <DropdownMenuTrigger asChild>
374
+ <Button
375
+ variant="secondary"
376
+ size="sm"
377
+ leadingIcon={<ColumnsIcon />}
378
+ trailingIcon={<CaretDown />}
379
+ className={TOOLBAR_BUTTON}
380
+ >
381
+ Columns
382
+ </Button>
383
+ </DropdownMenuTrigger>
384
+ <DropdownMenuContent align="end" className="w-54">
385
+ <DropdownMenuLabel className="text-[13px]">Active columns</DropdownMenuLabel>
386
+ {visibleColumns.map((c) => (
387
+ <DropdownMenuCheckboxItem
388
+ key={c.id}
389
+ checked
390
+ disabled={c.required}
391
+ onCheckedChange={() => setHidden((prev) => ({ ...prev, [c.id]: true }))}
392
+ onSelect={(e) => e.preventDefault()}
393
+ className="justify-between"
394
+ >
395
+ <span>{c.header}</span>
396
+ {c.required ? <Lock className="size-3.5 text-subtle" aria-hidden /> : null}
397
+ </DropdownMenuCheckboxItem>
398
+ ))}
399
+ {hiddenColumns.length > 0 ? (
400
+ <>
401
+ <DropdownMenuSeparator />
402
+ <DropdownMenuLabel className="text-[13px]">Available columns</DropdownMenuLabel>
403
+ {hiddenColumns.map((c) => (
404
+ <DropdownMenuCheckboxItem
405
+ key={c.id}
406
+ checked={false}
407
+ onCheckedChange={() => setHidden((prev) => ({ ...prev, [c.id]: false }))}
408
+ onSelect={(e) => e.preventDefault()}
409
+ >
410
+ {c.header}
411
+ </DropdownMenuCheckboxItem>
412
+ ))}
413
+ </>
414
+ ) : null}
415
+ </DropdownMenuContent>
416
+ </DropdownMenu>
417
+ {filters && filters.length > 0 ? (
332
418
  <DropdownMenu>
333
419
  <DropdownMenuTrigger asChild>
334
420
  <Button
335
421
  variant="secondary"
336
- size="xs"
337
- leadingIcon={<ColumnsIcon />}
338
- trailingIcon={<CaretDown />}
422
+ size="sm"
423
+ iconOnly
424
+ aria-label="Add filter"
425
+ className={TOOLBAR_BUTTON}
339
426
  >
340
- Columns
427
+ <FunnelSimple />
341
428
  </Button>
342
429
  </DropdownMenuTrigger>
343
- <DropdownMenuContent align="end" className="w-44">
344
- <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
345
- <DropdownMenuSeparator />
346
- {columns.map((c) => (
347
- <DropdownMenuCheckboxItem
348
- key={c.id}
349
- checked={!hidden[c.id]}
350
- onCheckedChange={(v) => setHidden((prev) => ({ ...prev, [c.id]: !v }))}
351
- onSelect={(e) => e.preventDefault()}
430
+ <DropdownMenuContent align="start" className="w-54">
431
+ {filters.map((filter) => (
432
+ <DropdownMenuItem
433
+ key={filter.id}
434
+ onSelect={() =>
435
+ setActiveFilterIds((prev) =>
436
+ prev.includes(filter.id) ? prev : [...prev, filter.id],
437
+ )
438
+ }
352
439
  >
353
- {c.header}
354
- </DropdownMenuCheckboxItem>
440
+ {filter.label}
441
+ </DropdownMenuItem>
355
442
  ))}
356
443
  </DropdownMenuContent>
357
444
  </DropdownMenu>
358
- {toolbarEnd}
359
- </div>
445
+ ) : null}
446
+ {toolbarEnd ? (
447
+ <div className="flex-0 sm:ml-auto [&_button]:h-8 [&_button]:rounded-[6px]">
448
+ {toolbarEnd}
449
+ </div>
450
+ ) : null}
360
451
  </div>
361
452
  )}
362
453
 
454
+ {activeFilters.length > 0 ? (
455
+ <div className="flex flex-wrap items-center gap-2">
456
+ {activeFilters.map((filter) => (
457
+ <DropdownMenu key={filter.id}>
458
+ <DropdownMenuTrigger asChild>
459
+ <Button
460
+ variant="outline"
461
+ size="sm"
462
+ className="rounded-[6px] bg-surface shadow-xs"
463
+ >
464
+ <FunnelSimple />
465
+ <span className="font-medium">{filter.label}</span>
466
+ <span className="h-4 w-px bg-line" aria-hidden />
467
+ <span className="text-muted">
468
+ {filter.value.length > 0
469
+ ? `${filter.value.length} selected`
470
+ : "Enter value"}
471
+ </span>
472
+ <CaretDown className="text-muted" />
473
+ </Button>
474
+ </DropdownMenuTrigger>
475
+ <DropdownMenuContent align="start" className="w-56">
476
+ <DropdownMenuLabel className="text-[13px]">
477
+ Filter by {filter.label}
478
+ </DropdownMenuLabel>
479
+ {filter.options.map((option) => {
480
+ const checked = filter.value.includes(option.value);
481
+ return (
482
+ <DropdownMenuCheckboxItem
483
+ key={option.value}
484
+ checked={checked}
485
+ onCheckedChange={() =>
486
+ filter.onChange(
487
+ checked
488
+ ? filter.value.filter((value) => value !== option.value)
489
+ : [...filter.value, option.value],
490
+ )
491
+ }
492
+ onSelect={(e) => e.preventDefault()}
493
+ >
494
+ {option.label}
495
+ </DropdownMenuCheckboxItem>
496
+ );
497
+ })}
498
+ <DropdownMenuSeparator />
499
+ <DropdownMenuItem
500
+ onSelect={() => {
501
+ filter.onChange([]);
502
+ setActiveFilterIds((prev) => prev.filter((id) => id !== filter.id));
503
+ }}
504
+ >
505
+ Clear filter
506
+ </DropdownMenuItem>
507
+ </DropdownMenuContent>
508
+ </DropdownMenu>
509
+ ))}
510
+ <button
511
+ type="button"
512
+ onClick={() => {
513
+ for (const filter of activeFilters) filter.onChange([]);
514
+ setActiveFilterIds([]);
515
+ }}
516
+ className="text-sm text-muted transition-colors hover:text-fg"
517
+ >
518
+ Clear filters
519
+ </button>
520
+ </div>
521
+ ) : null}
522
+
363
523
  {infoBanner}
364
524
 
365
525
  {selectable && selected.size > 0 && (
@@ -421,12 +581,16 @@ export function DataTable<T>({
421
581
  className={cn(HEADER_CELL, c.align === "right" && "text-right", c.className)}
422
582
  >
423
583
  {c.sortable ? (
424
- <button
584
+ <Button
425
585
  type="button"
586
+ variant="ghost"
587
+ size="xs"
426
588
  aria-label={`Sort by ${c.header}`}
427
589
  onClick={() => toggleSort(c)}
428
590
  className={cn(
429
- "inline-flex items-center gap-1.5 transition-colors hover:text-table-header-active",
591
+ "h-auto gap-1.5 rounded-none px-0 py-0 text-[0.8125rem] text-table-header transition-colors [&_svg]:size-3.5",
592
+ "hover:bg-transparent hover:text-table-header-active hover:shadow-none",
593
+ "active:scale-100 active:bg-transparent active:shadow-none",
430
594
  sort?.id === c.id && "text-table-header-active",
431
595
  )}
432
596
  >
@@ -440,7 +604,7 @@ export function DataTable<T>({
440
604
  ) : (
441
605
  <CaretUpDown className="size-3.5 opacity-50" />
442
606
  )}
443
- </button>
607
+ </Button>
444
608
  ) : (
445
609
  c.header
446
610
  )}
package/src/input.tsx CHANGED
@@ -1,4 +1,5 @@
1
- import { useId, forwardRef, type InputHTMLAttributes, type ReactNode } from "react";
1
+ import { X } from "@phosphor-icons/react";
2
+ import { forwardRef, useId, type InputHTMLAttributes, type ReactNode } from "react";
2
3
 
3
4
  import { cn } from "./utils";
4
5
 
@@ -47,6 +48,8 @@ export interface InputProps
47
48
  leadingIcon?: ReactNode;
48
49
  /** Optional icon rendered inside the field, after the text. */
49
50
  trailingIcon?: ReactNode;
51
+ /** Shows a real clear button when the field has a value. */
52
+ onClear?: () => void;
50
53
  /** Error styling; also pairs with `helperText` for the message. */
51
54
  error?: boolean;
52
55
  /** Helper or error message shown below the field (wired to aria-describedby). */
@@ -64,6 +67,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
64
67
  size = "md",
65
68
  leadingIcon,
66
69
  trailingIcon,
70
+ onClear,
71
+ value,
72
+ defaultValue,
67
73
  error = false,
68
74
  helperText,
69
75
  id,
@@ -77,6 +83,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
77
83
  const autoId = useId();
78
84
  const inputId = id ?? autoId;
79
85
  const hasMessage = Boolean(helperText);
86
+ const showClear = Boolean(onClear && (value !== undefined ? String(value).length > 0 : defaultValue !== undefined ? String(defaultValue).length > 0 : false));
80
87
  const messageId = hasMessage ? `${inputId}-description` : undefined;
81
88
  const describedBy = [ariaDescribedBy, messageId].filter(Boolean).join(" ") || undefined;
82
89
 
@@ -90,14 +97,15 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
90
97
  aria-invalid={error || undefined}
91
98
  aria-describedby={describedBy}
92
99
  style={
93
- leadingIcon || trailingIcon
100
+ leadingIcon || trailingIcon || showClear
94
101
  ? {
95
102
  paddingInlineStart: leadingIcon
96
103
  ? `calc(var(--control-pad-x-${size}) + var(--control-icon-${size}) + var(--control-gap))`
97
104
  : undefined,
98
- paddingInlineEnd: trailingIcon
99
- ? `calc(var(--control-pad-x-${size}) + var(--control-icon-${size}) + var(--control-gap))`
100
- : undefined,
105
+ paddingInlineEnd:
106
+ trailingIcon || showClear
107
+ ? `calc(var(--control-pad-x-${size}) + var(--control-icon-${size}) + var(--control-gap))`
108
+ : undefined,
101
109
  }
102
110
  : undefined
103
111
  }
@@ -116,12 +124,14 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
116
124
  readOnly && "cursor-default focus-visible:ring-0",
117
125
  className,
118
126
  )}
127
+ value={value}
128
+ defaultValue={defaultValue}
119
129
  {...props}
120
130
  />
121
131
  );
122
132
 
123
133
  // Bare input keeps the historical API when no compositional features are used.
124
- if (!leadingIcon && !trailingIcon && !hasMessage) {
134
+ if (!leadingIcon && !trailingIcon && !showClear && !hasMessage) {
125
135
  return field;
126
136
  }
127
137
 
@@ -142,10 +152,24 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
142
152
  </span>
143
153
  ) : null}
144
154
  {field}
145
- {trailingIcon ? (
155
+ {showClear ? (
156
+ <button
157
+ type="button"
158
+ aria-label="Clear search"
159
+ onClick={onClear}
160
+ className={cn(
161
+ "absolute inset-y-0 flex items-center text-[var(--input-placeholder)] transition-colors hover:text-[var(--input-fg)]",
162
+ TRAIL_INSET[size],
163
+ )}
164
+ >
165
+ <span className={cn("inline-flex items-center justify-center", ICON[size])}>
166
+ <X />
167
+ </span>
168
+ </button>
169
+ ) : trailingIcon ? (
146
170
  <span
147
171
  className={cn(
148
- "absolute inset-y-0 flex items-center text-[var(--input-placeholder)]",
172
+ "pointer-events-none absolute inset-y-0 flex items-center text-[var(--input-placeholder)]",
149
173
  TRAIL_INSET[size],
150
174
  )}
151
175
  aria-hidden