@asteby/metacore-runtime-react 28.3.1 → 28.3.3
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 +17 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +11 -0
- package/dist/icon-picker-field.d.ts.map +1 -1
- package/dist/icon-picker-field.js +36 -13
- package/package.json +1 -1
- package/src/__tests__/icon-picker-field.test.tsx +13 -8
- package/src/dialogs/dynamic-record.tsx +18 -0
- package/src/icon-picker-field.tsx +111 -56
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 28.3.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 99c476a: El selector de íconos es ahora un combobox: un botón que muestra el ícono y
|
|
8
|
+
nombre elegido y abre un popover al click (no la grilla siempre abierta), con
|
|
9
|
+
buscador, cada opción con su glifo y su nombre, y carga incremental al hacer
|
|
10
|
+
scroll (el catálogo lucide son ~1500 íconos).
|
|
11
|
+
|
|
12
|
+
## 28.3.2
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- 0fad478: El modal de crear/editar registro (DynamicRecordDialog) ahora renderiza el
|
|
17
|
+
selector de íconos para campos con tipo/widget "icon". Antes solo dynamic-form
|
|
18
|
+
lo manejaba, así que en el modal el campo caía a un input de texto vacío.
|
|
19
|
+
|
|
3
20
|
## 28.3.1
|
|
4
21
|
|
|
5
22
|
### Patch Changes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AAwC1C,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,yBAAyB,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"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"}
|
|
@@ -28,6 +28,7 @@ import { FieldCell } from '../field-grid';
|
|
|
28
28
|
import { isNilUuid, normalizeNilUuid } from '../nil-uuid';
|
|
29
29
|
import { normalizeRefFieldsForSubmit } from './normalize-submit';
|
|
30
30
|
import { DynamicIcon, isLucideIconName } from '../dynamic-icon';
|
|
31
|
+
import { IconPickerField } from '../icon-picker-field';
|
|
31
32
|
import { humanizeToken } from '../dynamic-columns-helpers';
|
|
32
33
|
import { formatDateCell } from '../dynamic-columns';
|
|
33
34
|
import { OptionBadge, statusColorFor, useIsDarkTheme, } from '../display-value';
|
|
@@ -874,6 +875,16 @@ export function EditField({ field, value, onChange, record }) {
|
|
|
874
875
|
if (field.type === 'textarea') {
|
|
875
876
|
return (_jsx(Textarea, { value: value ?? '', onChange: (e) => onChange(e.target.value), placeholder: field.placeholder, rows: 4 }));
|
|
876
877
|
}
|
|
878
|
+
// Icon picker: the kernel serves `type: 'icon'` (or an explicit
|
|
879
|
+
// `widget: 'icon'`) when the manifest column declares `widget: "icon"`.
|
|
880
|
+
// This modal renderer is SEPARATE from dynamic-form's FieldRenderer, so it
|
|
881
|
+
// needs its own branch — without it the field fell through to a raw text
|
|
882
|
+
// input. Same lucide-search-or-upload picker used elsewhere.
|
|
883
|
+
if (field.type === 'icon' || field.widget === 'icon') {
|
|
884
|
+
// IconPickerField types `field` as ActionFieldDef; the modal's FieldDef
|
|
885
|
+
// is structurally compatible for the props it reads (key/label/etc).
|
|
886
|
+
return (_jsx(IconPickerField, { field: field, value: value, onChange: onChange }));
|
|
887
|
+
}
|
|
877
888
|
// Media widgets: the kernel may serve an explicit `widget: 'upload'` (or the
|
|
878
889
|
// `image` type) for a file/photo column.
|
|
879
890
|
if (field.type === 'image' || field.widget === 'upload') {
|
|
@@ -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,+BAgJ/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,39 @@ 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, { className: "w-[--radix-popover-trigger-width] p-0", align: "start", children: [_jsx("div", { className: "p-2", children: _jsx(Input, { autoFocus: true, value: query, onChange: (e) => {
|
|
56
|
+
setQuery(e.target.value);
|
|
57
|
+
setLimit(PAGE);
|
|
58
|
+
}, placeholder: "Buscar \u00EDcono...", "aria-label": "Buscar \u00EDcono" }) }), _jsxs("div", { className: "max-h-64 overflow-y-auto", role: "listbox", "aria-label": "\u00CDconos", onScroll: (e) => {
|
|
59
|
+
const el = e.currentTarget;
|
|
60
|
+
// Near the bottom → reveal the next page.
|
|
61
|
+
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 24 &&
|
|
62
|
+
limit < matches.length) {
|
|
63
|
+
setLimit((n) => n + PAGE);
|
|
64
|
+
}
|
|
65
|
+
}, 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
66
|
}
|
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', () => {
|
|
@@ -57,6 +57,7 @@ import { FieldCell } from '../field-grid'
|
|
|
57
57
|
import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
|
|
58
58
|
import { normalizeRefFieldsForSubmit } from './normalize-submit'
|
|
59
59
|
import { DynamicIcon, isLucideIconName } from '../dynamic-icon'
|
|
60
|
+
import { IconPickerField } from '../icon-picker-field'
|
|
60
61
|
import { humanizeToken } from '../dynamic-columns-helpers'
|
|
61
62
|
import { formatDateCell } from '../dynamic-columns'
|
|
62
63
|
import {
|
|
@@ -1537,6 +1538,23 @@ export function EditField({ field, value, onChange, record }: {
|
|
|
1537
1538
|
)
|
|
1538
1539
|
}
|
|
1539
1540
|
|
|
1541
|
+
// Icon picker: the kernel serves `type: 'icon'` (or an explicit
|
|
1542
|
+
// `widget: 'icon'`) when the manifest column declares `widget: "icon"`.
|
|
1543
|
+
// This modal renderer is SEPARATE from dynamic-form's FieldRenderer, so it
|
|
1544
|
+
// needs its own branch — without it the field fell through to a raw text
|
|
1545
|
+
// input. Same lucide-search-or-upload picker used elsewhere.
|
|
1546
|
+
if (field.type === 'icon' || field.widget === 'icon') {
|
|
1547
|
+
// IconPickerField types `field` as ActionFieldDef; the modal's FieldDef
|
|
1548
|
+
// is structurally compatible for the props it reads (key/label/etc).
|
|
1549
|
+
return (
|
|
1550
|
+
<IconPickerField
|
|
1551
|
+
field={field as unknown as ActionFieldDef}
|
|
1552
|
+
value={value}
|
|
1553
|
+
onChange={onChange}
|
|
1554
|
+
/>
|
|
1555
|
+
)
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1540
1558
|
// Media widgets: the kernel may serve an explicit `widget: 'upload'` (or the
|
|
1541
1559
|
// `image` type) for a file/photo column.
|
|
1542
1560
|
if (field.type === 'image' || field.widget === 'upload') {
|
|
@@ -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,94 @@ 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
|
+
className="w-[--radix-popover-trigger-width] p-0"
|
|
133
|
+
align="start"
|
|
134
|
+
>
|
|
135
|
+
<div className="p-2">
|
|
136
|
+
<Input
|
|
137
|
+
autoFocus
|
|
138
|
+
value={query}
|
|
139
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
140
|
+
setQuery(e.target.value)
|
|
141
|
+
setLimit(PAGE)
|
|
142
|
+
}}
|
|
143
|
+
placeholder="Buscar ícono..."
|
|
144
|
+
aria-label="Buscar ícono"
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
147
|
+
<div
|
|
148
|
+
className="max-h-64 overflow-y-auto"
|
|
149
|
+
role="listbox"
|
|
150
|
+
aria-label="Íconos"
|
|
151
|
+
onScroll={(e: React.UIEvent<HTMLDivElement>) => {
|
|
152
|
+
const el = e.currentTarget
|
|
153
|
+
// Near the bottom → reveal the next page.
|
|
154
|
+
if (
|
|
155
|
+
el.scrollTop + el.clientHeight >= el.scrollHeight - 24 &&
|
|
156
|
+
limit < matches.length
|
|
157
|
+
) {
|
|
158
|
+
setLimit((n) => n + PAGE)
|
|
159
|
+
}
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
{visible.map((name) => (
|
|
163
|
+
<button
|
|
164
|
+
key={name}
|
|
165
|
+
type="button"
|
|
166
|
+
role="option"
|
|
167
|
+
aria-selected={name === selected}
|
|
168
|
+
aria-label={name}
|
|
169
|
+
onClick={() => pick(name)}
|
|
170
|
+
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm hover:bg-accent ${
|
|
171
|
+
name === selected ? 'bg-accent' : ''
|
|
172
|
+
}`}
|
|
173
|
+
>
|
|
174
|
+
<DynamicIcon name={name} className="h-4 w-4 shrink-0" />
|
|
175
|
+
<span className="truncate">{name}</span>
|
|
176
|
+
</button>
|
|
177
|
+
))}
|
|
178
|
+
{visible.length === 0 && (
|
|
179
|
+
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
180
|
+
Sin resultados
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
</PopoverContent>
|
|
185
|
+
</Popover>
|
|
131
186
|
)}
|
|
132
187
|
</div>
|
|
133
188
|
)
|