@asteby/metacore-runtime-react 28.0.3 → 28.2.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.
@@ -0,0 +1,134 @@
1
+ // IconPickerField — the `icon` widget renderer for DynamicForm. Two modes:
2
+ //
3
+ // - "Ícono" (default): search box + grid of lucide glyphs filtered by name.
4
+ // Clicking one stores the PascalCase lucide name ("CreditCard") as the
5
+ // field value — the same convention OptionDef.icon and image-ish cell
6
+ // renderers already understand (see dynamic-icon.tsx).
7
+ // - "Imagen": delegates to the existing UploadField untouched, so the value
8
+ // is the uploaded file url/path exactly as the `upload` widget stores it.
9
+ //
10
+ // The stored value stays a plain string (retrocompatible): a lucide name when
11
+ // an icon was picked, a url/path when an image was uploaded. When editing, the
12
+ // initial mode is inferred from the current value — path-like ("/" or ".")
13
+ // means image, anything else means icon.
14
+ import { useMemo, useState } from 'react'
15
+ import { icons } from 'lucide-react'
16
+ import { Button, Input } from '@asteby/metacore-ui/primitives'
17
+ import { DynamicIcon, resolveLucideIconName } from './dynamic-icon'
18
+ import { UploadField } from './upload-field'
19
+ import type { ActionFieldDef } from './types'
20
+
21
+ export interface IconPickerFieldProps {
22
+ field: ActionFieldDef
23
+ value: any
24
+ onChange: (v: any) => void
25
+ }
26
+
27
+ /** Max glyphs shown in the grid at once — keeps the DOM light while searching. */
28
+ const MAX_RESULTS = 48
29
+
30
+ const ALL_ICON_NAMES = Object.keys(icons)
31
+
32
+ /** True when a stored value looks like an image url/path rather than an icon name. */
33
+ export function looksLikeImageValue(value: unknown): boolean {
34
+ return typeof value === 'string' && value !== '' && /[/.]/.test(value)
35
+ }
36
+
37
+ export function IconPickerField({ field, value, onChange }: IconPickerFieldProps) {
38
+ const [mode, setMode] = useState<'icon' | 'image'>(() =>
39
+ looksLikeImageValue(value) ? 'image' : 'icon',
40
+ )
41
+ const [query, setQuery] = useState('')
42
+
43
+ const selected = mode === 'icon' ? resolveLucideIconName(value) : null
44
+
45
+ const results = useMemo(() => {
46
+ const q = query.trim().toLowerCase().replace(/[\s_-]/g, '')
47
+ const names = q
48
+ ? ALL_ICON_NAMES.filter((n) => n.toLowerCase().includes(q))
49
+ : ALL_ICON_NAMES
50
+ // Keep the current selection visible even when it doesn't match the query.
51
+ if (selected && !names.slice(0, MAX_RESULTS).includes(selected)) {
52
+ return [selected, ...names.filter((n) => n !== selected)].slice(0, MAX_RESULTS)
53
+ }
54
+ return names.slice(0, MAX_RESULTS)
55
+ }, [query, selected])
56
+
57
+ return (
58
+ <div className="flex flex-col gap-2">
59
+ <div className="flex gap-1" role="tablist" aria-label="Tipo de ícono">
60
+ <Button
61
+ type="button"
62
+ role="tab"
63
+ aria-selected={mode === 'icon'}
64
+ variant={mode === 'icon' ? 'secondary' : 'ghost'}
65
+ size="sm"
66
+ onClick={() => setMode('icon')}
67
+ >
68
+ Ícono
69
+ </Button>
70
+ <Button
71
+ type="button"
72
+ role="tab"
73
+ aria-selected={mode === 'image'}
74
+ variant={mode === 'image' ? 'secondary' : 'ghost'}
75
+ size="sm"
76
+ onClick={() => setMode('image')}
77
+ >
78
+ Imagen
79
+ </Button>
80
+ </div>
81
+ {mode === 'image' ? (
82
+ <UploadField field={field} value={value} onChange={onChange} />
83
+ ) : (
84
+ <div className="flex flex-col gap-2">
85
+ <div className="flex items-center gap-3">
86
+ <Input
87
+ id={field.key}
88
+ value={query}
89
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
90
+ placeholder="Buscar ícono..."
91
+ aria-label="Buscar ícono"
92
+ />
93
+ {selected && (
94
+ <div
95
+ data-testid="icon-picker-preview"
96
+ className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border bg-muted"
97
+ title={selected}
98
+ >
99
+ <DynamicIcon name={selected} className="h-6 w-6" />
100
+ </div>
101
+ )}
102
+ </div>
103
+ <div
104
+ className="grid max-h-56 grid-cols-8 gap-1 overflow-y-auto rounded-md border p-2"
105
+ role="listbox"
106
+ aria-label="Íconos"
107
+ >
108
+ {results.map((name) => (
109
+ <button
110
+ key={name}
111
+ type="button"
112
+ role="option"
113
+ aria-selected={name === selected}
114
+ aria-label={name}
115
+ title={name}
116
+ onClick={() => onChange(name)}
117
+ className={`flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent ${
118
+ name === selected ? 'bg-accent ring-1 ring-primary' : ''
119
+ }`}
120
+ >
121
+ <DynamicIcon name={name} className="h-4 w-4" />
122
+ </button>
123
+ ))}
124
+ {results.length === 0 && (
125
+ <span className="col-span-8 py-4 text-center text-sm text-muted-foreground">
126
+ Sin resultados
127
+ </span>
128
+ )}
129
+ </div>
130
+ </div>
131
+ )}
132
+ </div>
133
+ )
134
+ }
package/src/types.ts CHANGED
@@ -344,6 +344,7 @@ export type FieldWidget =
344
344
  | 'dynamic_select'
345
345
  | 'switch'
346
346
  | 'upload'
347
+ | 'icon'
347
348
 
348
349
  /**
349
350
  * Per-option visibility gate for a STATIC enum (`options[]`). The option is only