@asteby/metacore-runtime-react 28.3.2 → 28.3.4
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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 28.3.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 85ba41b: El popover del selector de íconos ya no se corta dentro de modales altos: su
|
|
8
|
+
alto se limita al espacio disponible que mide Radix (min 24rem / available
|
|
9
|
+
height) con layout flex (buscador fijo + lista que scrollea), abriendo hacia
|
|
10
|
+
abajo.
|
|
11
|
+
|
|
12
|
+
## 28.3.3
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- 99c476a: El selector de íconos es ahora un combobox: un botón que muestra el ícono y
|
|
17
|
+
nombre elegido y abre un popover al click (no la grilla siempre abierta), con
|
|
18
|
+
buscador, cada opción con su glifo y su nombre, y carga incremental al hacer
|
|
19
|
+
scroll (el catálogo lucide son ~1500 íconos).
|
|
20
|
+
|
|
3
21
|
## 28.3.2
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"icon-picker-field.d.ts","sourceRoot":"","sources":["../src/icon-picker-field.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"icon-picker-field.d.ts","sourceRoot":"","sources":["../src/icon-picker-field.tsx"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C,MAAM,WAAW,oBAAoB;IACjC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;CAC7B;AAQD,sFAAsF;AACtF,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE3D;AAED,wBAAgB,eAAe,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,oBAAoB,+BAsJ/E"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
// IconPickerField — the `icon` widget renderer for DynamicForm. Two modes:
|
|
3
3
|
//
|
|
4
4
|
// - "Ícono" (default): search box + grid of lucide glyphs filtered by name.
|
|
@@ -14,11 +14,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
14
14
|
// means image, anything else means icon.
|
|
15
15
|
import { useMemo, useState } from 'react';
|
|
16
16
|
import { icons } from 'lucide-react';
|
|
17
|
-
import {
|
|
17
|
+
import { ChevronsUpDown } from 'lucide-react';
|
|
18
|
+
import { Button, Input, Popover, PopoverContent, PopoverTrigger, } from '@asteby/metacore-ui/primitives';
|
|
18
19
|
import { DynamicIcon, resolveLucideIconName } from './dynamic-icon';
|
|
19
20
|
import { UploadField } from './upload-field';
|
|
20
|
-
/**
|
|
21
|
-
|
|
21
|
+
/** Icons rendered per "page" — grows as the user scrolls to the bottom. Keeps
|
|
22
|
+
* the DOM light: the lucide catalog is ~1500 glyphs, so we never mount them all. */
|
|
23
|
+
const PAGE = 60;
|
|
22
24
|
const ALL_ICON_NAMES = Object.keys(icons);
|
|
23
25
|
/** True when a stored value looks like an image url/path rather than an icon name. */
|
|
24
26
|
export function looksLikeImageValue(value) {
|
|
@@ -26,18 +28,49 @@ export function looksLikeImageValue(value) {
|
|
|
26
28
|
}
|
|
27
29
|
export function IconPickerField({ field, value, onChange }) {
|
|
28
30
|
const [mode, setMode] = useState(() => looksLikeImageValue(value) ? 'image' : 'icon');
|
|
31
|
+
const [open, setOpen] = useState(false);
|
|
29
32
|
const [query, setQuery] = useState('');
|
|
33
|
+
const [limit, setLimit] = useState(PAGE);
|
|
30
34
|
const selected = mode === 'icon' ? resolveLucideIconName(value) : null;
|
|
31
|
-
|
|
35
|
+
// Full match list (names only) — filtered by query, not yet capped.
|
|
36
|
+
const matches = useMemo(() => {
|
|
32
37
|
const q = query.trim().toLowerCase().replace(/[\s_-]/g, '');
|
|
33
|
-
|
|
38
|
+
return q
|
|
34
39
|
? ALL_ICON_NAMES.filter((n) => n.toLowerCase().includes(q))
|
|
35
40
|
: ALL_ICON_NAMES;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
}, [query]);
|
|
42
|
+
// Visible slice — grows on scroll (see onScroll below).
|
|
43
|
+
const visible = matches.slice(0, limit);
|
|
44
|
+
const pick = (name) => {
|
|
45
|
+
onChange(name);
|
|
46
|
+
setOpen(false);
|
|
47
|
+
};
|
|
48
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex gap-1", role: "tablist", "aria-label": "Tipo de \u00EDcono", children: [_jsx(Button, { type: "button", role: "tab", "aria-selected": mode === 'icon', variant: mode === 'icon' ? 'secondary' : 'ghost', size: "sm", onClick: () => setMode('icon'), children: "\u00CDcono" }), _jsx(Button, { type: "button", role: "tab", "aria-selected": mode === 'image', variant: mode === 'image' ? 'secondary' : 'ghost', size: "sm", onClick: () => setMode('image'), children: "Imagen" })] }), mode === 'image' ? (_jsx(UploadField, { field: field, value: value, onChange: onChange })) : (_jsxs(Popover, { open: open, onOpenChange: (o) => {
|
|
49
|
+
setOpen(o);
|
|
50
|
+
if (o) {
|
|
51
|
+
// Fresh open: reset the search + paging.
|
|
52
|
+
setQuery('');
|
|
53
|
+
setLimit(PAGE);
|
|
54
|
+
}
|
|
55
|
+
}, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { id: field.key, type: "button", variant: "outline", role: "combobox", "aria-expanded": open, className: "w-full justify-between font-normal", children: [_jsx("span", { className: "flex min-w-0 items-center gap-2", children: selected ? (_jsxs(_Fragment, { children: [_jsx(DynamicIcon, { name: selected, className: "h-4 w-4 shrink-0" }), _jsx("span", { className: "truncate", children: selected })] })) : (_jsx("span", { className: "text-muted-foreground", children: "Selecciona un \u00EDcono\u2026" })) }), _jsx(ChevronsUpDown, { className: "h-4 w-4 shrink-0 opacity-50" })] }) }), _jsxs(PopoverContent
|
|
56
|
+
// Cap the panel to the space Radix measured between the
|
|
57
|
+
// trigger and the viewport edge so it can NEVER overflow
|
|
58
|
+
// off-screen (the old fixed max-h clipped inside tall
|
|
59
|
+
// modals). Prefer opening downward; flip only if needed.
|
|
60
|
+
, {
|
|
61
|
+
// Cap the panel to the space Radix measured between the
|
|
62
|
+
// trigger and the viewport edge so it can NEVER overflow
|
|
63
|
+
// off-screen (the old fixed max-h clipped inside tall
|
|
64
|
+
// modals). Prefer opening downward; flip only if needed.
|
|
65
|
+
className: "flex max-h-[min(24rem,var(--radix-popover-content-available-height))] w-[--radix-popover-trigger-width] flex-col overflow-hidden p-0", align: "start", side: "bottom", sideOffset: 4, children: [_jsx("div", { className: "shrink-0 p-2", children: _jsx(Input, { autoFocus: true, value: query, onChange: (e) => {
|
|
66
|
+
setQuery(e.target.value);
|
|
67
|
+
setLimit(PAGE);
|
|
68
|
+
}, placeholder: "Buscar \u00EDcono...", "aria-label": "Buscar \u00EDcono" }) }), _jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto", role: "listbox", "aria-label": "\u00CDconos", onScroll: (e) => {
|
|
69
|
+
const el = e.currentTarget;
|
|
70
|
+
// Near the bottom → reveal the next page.
|
|
71
|
+
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 24 &&
|
|
72
|
+
limit < matches.length) {
|
|
73
|
+
setLimit((n) => n + PAGE);
|
|
74
|
+
}
|
|
75
|
+
}, children: [visible.map((name) => (_jsxs("button", { type: "button", role: "option", "aria-selected": name === selected, "aria-label": name, onClick: () => pick(name), className: `flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm hover:bg-accent ${name === selected ? 'bg-accent' : ''}`, children: [_jsx(DynamicIcon, { name: name, className: "h-4 w-4 shrink-0" }), _jsx("span", { className: "truncate", children: name })] }, name))), visible.length === 0 && (_jsx("div", { className: "py-6 text-center text-sm text-muted-foreground", children: "Sin resultados" }))] })] })] }))] }));
|
|
43
76
|
}
|
package/package.json
CHANGED
|
@@ -20,26 +20,31 @@ afterEach(cleanup)
|
|
|
20
20
|
const field: ActionFieldDef = { key: 'icon', label: 'Ícono', type: 'text', widget: 'icon' }
|
|
21
21
|
|
|
22
22
|
describe('IconPickerField', () => {
|
|
23
|
-
|
|
23
|
+
// The icon mode is a combobox: the list lives in a popover that opens on
|
|
24
|
+
// click. Open it, then search, then pick.
|
|
25
|
+
const openPopover = () =>
|
|
26
|
+
fireEvent.click(screen.getByRole('combobox'))
|
|
27
|
+
|
|
28
|
+
it('abre el popover, busca "credit" y al click emite onChange con el nombre', () => {
|
|
24
29
|
const onChange = vi.fn()
|
|
25
30
|
render(<IconPickerField field={field} value="" onChange={onChange} />)
|
|
31
|
+
openPopover()
|
|
26
32
|
fireEvent.change(screen.getByLabelText('Buscar ícono'), { target: { value: 'credit' } })
|
|
27
33
|
const option = screen.getByRole('option', { name: 'CreditCard' })
|
|
28
34
|
fireEvent.click(option)
|
|
29
35
|
expect(onChange).toHaveBeenCalledWith('CreditCard')
|
|
30
36
|
})
|
|
31
37
|
|
|
32
|
-
it('
|
|
38
|
+
it('el trigger muestra el ícono y nombre seleccionado', () => {
|
|
33
39
|
render(<IconPickerField field={field} value="CreditCard" onChange={() => {}} />)
|
|
34
|
-
|
|
35
|
-
expect(
|
|
36
|
-
screen.getByRole('option', { name: 'CreditCard' }).getAttribute('aria-selected'),
|
|
37
|
-
).toBe('true')
|
|
40
|
+
// The combobox trigger shows the selected name even before opening.
|
|
41
|
+
expect(screen.getByRole('combobox').textContent).toContain('CreditCard')
|
|
38
42
|
})
|
|
39
43
|
|
|
40
|
-
it('
|
|
44
|
+
it('pagina: arranca con a lo sumo 60 opciones visibles', () => {
|
|
41
45
|
render(<IconPickerField field={field} value="" onChange={() => {}} />)
|
|
42
|
-
|
|
46
|
+
openPopover()
|
|
47
|
+
expect(screen.getAllByRole('option').length).toBeLessThanOrEqual(60)
|
|
43
48
|
})
|
|
44
49
|
|
|
45
50
|
it('modo imagen delega a UploadField con los mismos props', () => {
|
|
@@ -13,7 +13,14 @@
|
|
|
13
13
|
// means image, anything else means icon.
|
|
14
14
|
import { useMemo, useState } from 'react'
|
|
15
15
|
import { icons } from 'lucide-react'
|
|
16
|
-
import {
|
|
16
|
+
import { ChevronsUpDown } from 'lucide-react'
|
|
17
|
+
import {
|
|
18
|
+
Button,
|
|
19
|
+
Input,
|
|
20
|
+
Popover,
|
|
21
|
+
PopoverContent,
|
|
22
|
+
PopoverTrigger,
|
|
23
|
+
} from '@asteby/metacore-ui/primitives'
|
|
17
24
|
import { DynamicIcon, resolveLucideIconName } from './dynamic-icon'
|
|
18
25
|
import { UploadField } from './upload-field'
|
|
19
26
|
import type { ActionFieldDef } from './types'
|
|
@@ -24,8 +31,9 @@ export interface IconPickerFieldProps {
|
|
|
24
31
|
onChange: (v: any) => void
|
|
25
32
|
}
|
|
26
33
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
34
|
+
/** Icons rendered per "page" — grows as the user scrolls to the bottom. Keeps
|
|
35
|
+
* the DOM light: the lucide catalog is ~1500 glyphs, so we never mount them all. */
|
|
36
|
+
const PAGE = 60
|
|
29
37
|
|
|
30
38
|
const ALL_ICON_NAMES = Object.keys(icons)
|
|
31
39
|
|
|
@@ -38,21 +46,27 @@ export function IconPickerField({ field, value, onChange }: IconPickerFieldProps
|
|
|
38
46
|
const [mode, setMode] = useState<'icon' | 'image'>(() =>
|
|
39
47
|
looksLikeImageValue(value) ? 'image' : 'icon',
|
|
40
48
|
)
|
|
49
|
+
const [open, setOpen] = useState(false)
|
|
41
50
|
const [query, setQuery] = useState('')
|
|
51
|
+
const [limit, setLimit] = useState(PAGE)
|
|
42
52
|
|
|
43
53
|
const selected = mode === 'icon' ? resolveLucideIconName(value) : null
|
|
44
54
|
|
|
45
|
-
|
|
55
|
+
// Full match list (names only) — filtered by query, not yet capped.
|
|
56
|
+
const matches = useMemo(() => {
|
|
46
57
|
const q = query.trim().toLowerCase().replace(/[\s_-]/g, '')
|
|
47
|
-
|
|
58
|
+
return q
|
|
48
59
|
? ALL_ICON_NAMES.filter((n) => n.toLowerCase().includes(q))
|
|
49
60
|
: ALL_ICON_NAMES
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
61
|
+
}, [query])
|
|
62
|
+
|
|
63
|
+
// Visible slice — grows on scroll (see onScroll below).
|
|
64
|
+
const visible = matches.slice(0, limit)
|
|
65
|
+
|
|
66
|
+
const pick = (name: string) => {
|
|
67
|
+
onChange(name)
|
|
68
|
+
setOpen(false)
|
|
69
|
+
}
|
|
56
70
|
|
|
57
71
|
return (
|
|
58
72
|
<div className="flex flex-col gap-2">
|
|
@@ -81,53 +95,100 @@ export function IconPickerField({ field, value, onChange }: IconPickerFieldProps
|
|
|
81
95
|
{mode === 'image' ? (
|
|
82
96
|
<UploadField field={field} value={value} onChange={onChange} />
|
|
83
97
|
) : (
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
98
|
+
<Popover
|
|
99
|
+
open={open}
|
|
100
|
+
onOpenChange={(o: boolean) => {
|
|
101
|
+
setOpen(o)
|
|
102
|
+
if (o) {
|
|
103
|
+
// Fresh open: reset the search + paging.
|
|
104
|
+
setQuery('')
|
|
105
|
+
setLimit(PAGE)
|
|
106
|
+
}
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
<PopoverTrigger asChild>
|
|
110
|
+
<Button
|
|
87
111
|
id={field.key}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
aria-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
112
|
+
type="button"
|
|
113
|
+
variant="outline"
|
|
114
|
+
role="combobox"
|
|
115
|
+
aria-expanded={open}
|
|
116
|
+
className="w-full justify-between font-normal"
|
|
117
|
+
>
|
|
118
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
119
|
+
{selected ? (
|
|
120
|
+
<>
|
|
121
|
+
<DynamicIcon name={selected} className="h-4 w-4 shrink-0" />
|
|
122
|
+
<span className="truncate">{selected}</span>
|
|
123
|
+
</>
|
|
124
|
+
) : (
|
|
125
|
+
<span className="text-muted-foreground">Selecciona un ícono…</span>
|
|
126
|
+
)}
|
|
127
127
|
</span>
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
|
129
|
+
</Button>
|
|
130
|
+
</PopoverTrigger>
|
|
131
|
+
<PopoverContent
|
|
132
|
+
// Cap the panel to the space Radix measured between the
|
|
133
|
+
// trigger and the viewport edge so it can NEVER overflow
|
|
134
|
+
// off-screen (the old fixed max-h clipped inside tall
|
|
135
|
+
// modals). Prefer opening downward; flip only if needed.
|
|
136
|
+
className="flex max-h-[min(24rem,var(--radix-popover-content-available-height))] w-[--radix-popover-trigger-width] flex-col overflow-hidden p-0"
|
|
137
|
+
align="start"
|
|
138
|
+
side="bottom"
|
|
139
|
+
sideOffset={4}
|
|
140
|
+
>
|
|
141
|
+
<div className="shrink-0 p-2">
|
|
142
|
+
<Input
|
|
143
|
+
autoFocus
|
|
144
|
+
value={query}
|
|
145
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
146
|
+
setQuery(e.target.value)
|
|
147
|
+
setLimit(PAGE)
|
|
148
|
+
}}
|
|
149
|
+
placeholder="Buscar ícono..."
|
|
150
|
+
aria-label="Buscar ícono"
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
<div
|
|
154
|
+
className="min-h-0 flex-1 overflow-y-auto"
|
|
155
|
+
role="listbox"
|
|
156
|
+
aria-label="Íconos"
|
|
157
|
+
onScroll={(e: React.UIEvent<HTMLDivElement>) => {
|
|
158
|
+
const el = e.currentTarget
|
|
159
|
+
// Near the bottom → reveal the next page.
|
|
160
|
+
if (
|
|
161
|
+
el.scrollTop + el.clientHeight >= el.scrollHeight - 24 &&
|
|
162
|
+
limit < matches.length
|
|
163
|
+
) {
|
|
164
|
+
setLimit((n) => n + PAGE)
|
|
165
|
+
}
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
{visible.map((name) => (
|
|
169
|
+
<button
|
|
170
|
+
key={name}
|
|
171
|
+
type="button"
|
|
172
|
+
role="option"
|
|
173
|
+
aria-selected={name === selected}
|
|
174
|
+
aria-label={name}
|
|
175
|
+
onClick={() => pick(name)}
|
|
176
|
+
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm hover:bg-accent ${
|
|
177
|
+
name === selected ? 'bg-accent' : ''
|
|
178
|
+
}`}
|
|
179
|
+
>
|
|
180
|
+
<DynamicIcon name={name} className="h-4 w-4 shrink-0" />
|
|
181
|
+
<span className="truncate">{name}</span>
|
|
182
|
+
</button>
|
|
183
|
+
))}
|
|
184
|
+
{visible.length === 0 && (
|
|
185
|
+
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
186
|
+
Sin resultados
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
</PopoverContent>
|
|
191
|
+
</Popover>
|
|
131
192
|
)}
|
|
132
193
|
</div>
|
|
133
194
|
)
|