@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":"AAkBA,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;AAOD,sFAAsF;AACtF,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE3D;AAED,wBAAgB,eAAe,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,oBAAoB,+BAiG/E"}
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 { Button, Input } from '@asteby/metacore-ui/primitives';
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
- /** Max glyphs shown in the grid at once keeps the DOM light while searching. */
21
- const MAX_RESULTS = 48;
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
- const results = useMemo(() => {
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
- const names = q
38
+ return q
34
39
  ? ALL_ICON_NAMES.filter((n) => n.toLowerCase().includes(q))
35
40
  : ALL_ICON_NAMES;
36
- // Keep the current selection visible even when it doesn't match the query.
37
- if (selected && !names.slice(0, MAX_RESULTS).includes(selected)) {
38
- return [selected, ...names.filter((n) => n !== selected)].slice(0, MAX_RESULTS);
39
- }
40
- return names.slice(0, MAX_RESULTS);
41
- }, [query, selected]);
42
- 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("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Input, { id: field.key, value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Buscar \u00EDcono...", "aria-label": "Buscar \u00EDcono" }), selected && (_jsx("div", { "data-testid": "icon-picker-preview", className: "flex h-10 w-10 shrink-0 items-center justify-center rounded-md border bg-muted", title: selected, children: _jsx(DynamicIcon, { name: selected, className: "h-6 w-6" }) }))] }), _jsxs("div", { className: "grid max-h-56 grid-cols-8 gap-1 overflow-y-auto rounded-md border p-2", role: "listbox", "aria-label": "\u00CDconos", children: [results.map((name) => (_jsx("button", { type: "button", role: "option", "aria-selected": name === selected, "aria-label": name, title: name, onClick: () => onChange(name), className: `flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent ${name === selected ? 'bg-accent ring-1 ring-primary' : ''}`, children: _jsx(DynamicIcon, { name: name, className: "h-4 w-4" }) }, name))), results.length === 0 && (_jsx("span", { className: "col-span-8 py-4 text-center text-sm text-muted-foreground", children: "Sin resultados" }))] })] }))] }));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "28.3.2",
3
+ "version": "28.3.4",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- it('busca "credit", muestra CreditCard y al click emite onChange con el nombre', () => {
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('resalta el seleccionado y muestra el preview grande', () => {
38
+ it('el trigger muestra el ícono y nombre seleccionado', () => {
33
39
  render(<IconPickerField field={field} value="CreditCard" onChange={() => {}} />)
34
- expect(screen.getByTestId('icon-picker-preview')).toBeTruthy()
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('limita el grid a 48 resultados', () => {
44
+ it('pagina: arranca con a lo sumo 60 opciones visibles', () => {
41
45
  render(<IconPickerField field={field} value="" onChange={() => {}} />)
42
- expect(screen.getAllByRole('option').length).toBeLessThanOrEqual(48)
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 { Button, Input } from '@asteby/metacore-ui/primitives'
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
- /** Max glyphs shown in the grid at once keeps the DOM light while searching. */
28
- const MAX_RESULTS = 48
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
- const results = useMemo(() => {
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
- const names = q
58
+ return q
48
59
  ? ALL_ICON_NAMES.filter((n) => n.toLowerCase().includes(q))
49
60
  : 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])
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
- <div className="flex flex-col gap-2">
85
- <div className="flex items-center gap-3">
86
- <Input
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
- 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
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
- </div>
130
- </div>
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
  )