@r2digisolutions/ui 0.23.0 → 0.24.1

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.
@@ -1,5 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { TContextMenuEntry } from '../core/types.js';
3
+ import { portal } from '../utils/portal.js';
4
+ import { clampToViewport } from '../utils/position.js';
3
5
 
4
6
  type Props = {
5
7
  items?: TContextMenuEntry[];
@@ -9,6 +11,8 @@
9
11
  title?: string;
10
12
  searchable?: boolean;
11
13
  context?: any;
14
+ // por si algún día quieres desactivar el portal:
15
+ portalTarget?: HTMLElement | null;
12
16
  };
13
17
  let {
14
18
  items = [],
@@ -17,7 +21,8 @@
17
21
  open = $bindable(false),
18
22
  title = '',
19
23
  searchable = true,
20
- context = null
24
+ context = null,
25
+ portalTarget = null
21
26
  }: Props = $props();
22
27
 
23
28
  let stack = $state<{ label: string; items: TContextMenuEntry[] }[]>([]);
@@ -61,7 +66,6 @@
61
66
  const query = q.trim().toLowerCase();
62
67
  let arr = query ? list.filter((it) => matches(it, query)) : list.slice();
63
68
 
64
- // limpiar divisores (sin duplicados, ni al principio/fin)
65
69
  const out: TContextMenuEntry[] = [];
66
70
  let prevDiv = false;
67
71
  for (const it of arr) {
@@ -100,20 +104,44 @@
100
104
  document.addEventListener('keydown', handler);
101
105
  return () => document.removeEventListener('keydown', handler);
102
106
  });
107
+
108
+ // --- CLAMP dinámico al abrir o al cambiar x/y ---
109
+ let menuEl: HTMLDivElement | null = $state(null);
110
+
111
+ $effect(() => {
112
+ if (!open || !menuEl) return;
113
+ // siguiente frame para medir dimensiones reales
114
+ requestAnimationFrame(() => {
115
+ if (!menuEl) return;
116
+ const rect = menuEl.getBoundingClientRect();
117
+ const { x: nx, y: ny } = clampToViewport(x, y, rect.width, rect.height, 8);
118
+ // solo si cambian, re-ubica
119
+ if (nx !== x || ny !== y) {
120
+ x = nx;
121
+ y = ny;
122
+ }
123
+ });
124
+ });
103
125
  </script>
104
126
 
105
127
  {#if open}
128
+ <!-- BACKDROP: va al body por el portal también -->
106
129
  <div
130
+ use:portal={portalTarget}
107
131
  role="dialog"
108
- class="fixed inset-0 z-40"
132
+ class="fixed inset-0 z-[2147483646]"
109
133
  onclick={() => close()}
110
134
  oncontextmenu={(e) => e.preventDefault()}
111
135
  aria-modal="true"
112
136
  tabindex="0"
137
+ style="pointer-events:auto"
113
138
  />
114
139
 
140
+ <!-- MENU: fijado al viewport y portaleado al body -->
115
141
  <div
116
- class="fixed z-50 w-72 rounded-2xl bg-white p-2 shadow-xl ring-1 ring-black/5 dark:bg-gray-900"
142
+ bind:this={menuEl}
143
+ use:portal={portalTarget}
144
+ class="fixed z-[2147483647] w-72 rounded-2xl bg-white p-2 shadow-xl ring-1 ring-black/5 dark:bg-gray-900"
117
145
  style={`left:${x}px; top:${y}px`}
118
146
  oncontextmenu={(e) => e.preventDefault()}
119
147
  >
@@ -133,8 +161,10 @@
133
161
  stroke="currentColor"
134
162
  stroke-width="2"
135
163
  stroke-linecap="round"
136
- stroke-linejoin="round"><polyline points="15 18 9 12 15 6" /></svg
164
+ stroke-linejoin="round"
137
165
  >
166
+ <polyline points="15 18 9 12 15 6" />
167
+ </svg>
138
168
  </button>
139
169
  {/if}
140
170
  <div class="min-w-0 flex-1 truncate px-1 text-xs font-medium opacity-70">
@@ -181,8 +211,10 @@
181
211
  stroke="currentColor"
182
212
  stroke-width="2"
183
213
  stroke-linecap="round"
184
- stroke-linejoin="round"><polyline points="9 18 15 12 9 6" /></svg
214
+ stroke-linejoin="round"
185
215
  >
216
+ <polyline points="9 18 15 12 9 6" />
217
+ </svg>
186
218
  {/if}
187
219
  </span>
188
220
  </button>
@@ -7,6 +7,7 @@ type Props = {
7
7
  title?: string;
8
8
  searchable?: boolean;
9
9
  context?: any;
10
+ portalTarget?: HTMLElement | null;
10
11
  };
11
12
  declare const ContextMenu: import("svelte").Component<Props, {}, "open">;
12
13
  type ContextMenu = ReturnType<typeof ContextMenu>;
@@ -0,0 +1,3 @@
1
+ export declare function portal(node: HTMLElement, target?: HTMLElement | null): {
2
+ destroy(): void;
3
+ };
@@ -0,0 +1,13 @@
1
+ export function portal(node, target = null) {
2
+ const tgt = target ?? document.body;
3
+ const placeholder = document.createComment('portal-placeholder');
4
+ node.parentNode?.insertBefore(placeholder, node);
5
+ tgt.appendChild(node);
6
+ return {
7
+ destroy() {
8
+ node.remove();
9
+ placeholder.parentNode?.insertBefore(node, placeholder);
10
+ placeholder.remove();
11
+ }
12
+ };
13
+ }
@@ -0,0 +1,4 @@
1
+ export declare function clampToViewport(x: number, y: number, menuW: number, menuH: number, padding?: number): {
2
+ x: number;
3
+ y: number;
4
+ };
@@ -0,0 +1,11 @@
1
+ export function clampToViewport(x, y, menuW, menuH, padding = 8) {
2
+ const vw = document.documentElement.clientWidth;
3
+ const vh = document.documentElement.clientHeight;
4
+ let nx = x;
5
+ let ny = y;
6
+ if (nx + menuW + padding > vw)
7
+ nx = Math.max(padding, vw - menuW - padding);
8
+ if (ny + menuH + padding > vh)
9
+ ny = Math.max(padding, vh - menuH - padding);
10
+ return { x: nx, y: ny };
11
+ }
@@ -27,4 +27,5 @@ import DialogTitle from './ui/Dialog/DialogTitle.svelte';
27
27
  import DialogDescription from './ui/Dialog/DialogDescription.svelte';
28
28
  import DialogContent from './ui/Dialog/DialogContent.svelte';
29
29
  import DialogFooter from './ui/Dialog/DialogFooter.svelte';
30
- export { Dialog, DialogHeader, DialogTitle, DialogDescription, DialogContent, DialogFooter, Tag, NoContent, Alert, Avatar, Button, Badge, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Container, Checkbox, Field, Section, Loading, TableList, Heading, Label, Input, InputRadio, Textarea, };
30
+ import Selector from './ui/Selector/Selector.svelte';
31
+ export { Selector, Dialog, DialogHeader, DialogTitle, DialogDescription, DialogContent, DialogFooter, Tag, NoContent, Alert, Avatar, Button, Badge, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Container, Checkbox, Field, Section, Loading, TableList, Heading, Label, Input, InputRadio, Textarea, };
@@ -27,4 +27,5 @@ import DialogTitle from './ui/Dialog/DialogTitle.svelte';
27
27
  import DialogDescription from './ui/Dialog/DialogDescription.svelte';
28
28
  import DialogContent from './ui/Dialog/DialogContent.svelte';
29
29
  import DialogFooter from './ui/Dialog/DialogFooter.svelte';
30
- export { Dialog, DialogHeader, DialogTitle, DialogDescription, DialogContent, DialogFooter, Tag, NoContent, Alert, Avatar, Button, Badge, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Container, Checkbox, Field, Section, Loading, TableList, Heading, Label, Input, InputRadio, Textarea, };
30
+ import Selector from './ui/Selector/Selector.svelte';
31
+ export { Selector, Dialog, DialogHeader, DialogTitle, DialogDescription, DialogContent, DialogFooter, Tag, NoContent, Alert, Avatar, Button, Badge, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Container, Checkbox, Field, Section, Loading, TableList, Heading, Label, Input, InputRadio, Textarea, };
@@ -0,0 +1,102 @@
1
+ <script lang="ts" generics="M extends string">
2
+ interface ModeOption<M extends string> {
3
+ id: M;
4
+ label: string;
5
+ icon?: any;
6
+ disabled?: boolean;
7
+ tooltip?: string;
8
+ }
9
+
10
+ interface Props<M extends string> {
11
+ modes: ModeOption<M>[];
12
+ currentMode: M; // bindable
13
+ onModeChange?: (m: M) => void;
14
+ activation?: 'auto' | 'manual';
15
+ }
16
+
17
+ let {
18
+ modes,
19
+ currentMode = $bindable<M>(),
20
+ onModeChange,
21
+ activation = 'auto'
22
+ }: Props<M> = $props();
23
+
24
+ const buttons = new Map<M, HTMLButtonElement>();
25
+
26
+ function focusActive() {
27
+ const el = buttons.get(currentMode);
28
+ queueMicrotask(() => el?.focus());
29
+ }
30
+
31
+ function change(m: M) {
32
+ if (m === currentMode) return;
33
+ currentMode = m;
34
+ onModeChange?.(m);
35
+ focusActive();
36
+ }
37
+
38
+ function onKeydown(e: KeyboardEvent) {
39
+ const horiz = e.key === 'ArrowLeft' || e.key === 'ArrowRight';
40
+ const vert = e.key === 'ArrowUp' || e.key === 'ArrowDown';
41
+ const home = e.key === 'Home';
42
+ const end = e.key === 'End';
43
+
44
+ if (!horiz && !vert && !home && !end) return;
45
+
46
+ e.preventDefault();
47
+ const enabled = modes.filter((m) => !m.disabled);
48
+ if (enabled.length === 0) return;
49
+
50
+ const curIdx = enabled.findIndex((m) => m.id === currentMode);
51
+ const idx = curIdx === -1 ? 0 : curIdx;
52
+
53
+ if (home) return change(enabled[0].id);
54
+ if (end) return change(enabled[enabled.length - 1].id);
55
+
56
+ const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : -1;
57
+ const nextIndex = (idx + dir + enabled.length) % enabled.length;
58
+ change(enabled[nextIndex].id);
59
+ }
60
+ </script>
61
+
62
+ <div
63
+ class="flex rounded-lg bg-gray-100 p-1"
64
+ role="tablist"
65
+ aria-label="Seleccionar vista"
66
+ aria-orientation="horizontal"
67
+ onkeydown={onKeydown}
68
+ tabindex="0"
69
+ >
70
+ {#each modes as m (m.id)}
71
+ <button
72
+ bind:this={
73
+ () => buttons.get(m.id),
74
+ (el) => {
75
+ if (el) buttons.set(m.id, el);
76
+ }
77
+ }
78
+ type="button"
79
+ role="tab"
80
+ aria-selected={currentMode === m.id}
81
+ aria-controls={undefined}
82
+ disabled={m.disabled}
83
+ tabindex={currentMode === m.id ? 0 : -1}
84
+ onfocus={() => {
85
+ if (activation === 'auto' && !m.disabled && currentMode !== m.id) {
86
+ change(m.id);
87
+ }
88
+ }}
89
+ onclick={() => !m.disabled && change(m.id)}
90
+ class={`flex items-center space-x-1 rounded-md px-3 py-2 text-sm font-medium transition-all
91
+ ${currentMode === m.id ? 'bg-white text-purple-600 shadow-sm' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}
92
+ ${m.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}
93
+ focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-500 focus-visible:ring-offset-1`}
94
+ title={m.tooltip ?? m.label}
95
+ >
96
+ {#if m.icon}
97
+ <m.icon class="h-4 w-4" />
98
+ {/if}
99
+ <span class="hidden sm:inline">{m.label}</span>
100
+ </button>
101
+ {/each}
102
+ </div>
@@ -0,0 +1,37 @@
1
+ interface ModeOption<M extends string> {
2
+ id: M;
3
+ label: string;
4
+ icon?: any;
5
+ disabled?: boolean;
6
+ tooltip?: string;
7
+ }
8
+ interface Props<M extends string> {
9
+ modes: ModeOption<M>[];
10
+ currentMode: M;
11
+ onModeChange?: (m: M) => void;
12
+ activation?: 'auto' | 'manual';
13
+ }
14
+ declare function $$render<M extends string>(): {
15
+ props: Props<M>;
16
+ exports: {};
17
+ bindings: "currentMode";
18
+ slots: {};
19
+ events: {};
20
+ };
21
+ declare class __sveltets_Render<M extends string> {
22
+ props(): ReturnType<typeof $$render<M>>['props'];
23
+ events(): ReturnType<typeof $$render<M>>['events'];
24
+ slots(): ReturnType<typeof $$render<M>>['slots'];
25
+ bindings(): "currentMode";
26
+ exports(): {};
27
+ }
28
+ interface $$IsomorphicComponent {
29
+ new <M extends string>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<M>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<M>['props']>, ReturnType<__sveltets_Render<M>['events']>, ReturnType<__sveltets_Render<M>['slots']>> & {
30
+ $$bindings?: ReturnType<__sveltets_Render<M>['bindings']>;
31
+ } & ReturnType<__sveltets_Render<M>['exports']>;
32
+ <M extends string>(internal: unknown, props: ReturnType<__sveltets_Render<M>['props']> & {}): ReturnType<__sveltets_Render<M>['exports']>;
33
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
34
+ }
35
+ declare const Selector: $$IsomorphicComponent;
36
+ type Selector<M extends string> = InstanceType<typeof Selector<M>>;
37
+ export default Selector;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/ui",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
4
4
  "private": false,
5
5
  "packageManager": "bun@1.2.23",
6
6
  "publishConfig": {
@@ -53,20 +53,20 @@
53
53
  "@playwright/test": "^1.55.1",
54
54
  "@storybook/addon-essentials": "^8.6.14",
55
55
  "@storybook/addon-interactions": "^8.6.14",
56
- "@storybook/addon-svelte-csf": "5.0.8",
56
+ "@storybook/addon-svelte-csf": "5.0.10",
57
57
  "@storybook/blocks": "^8.6.14",
58
- "@storybook/svelte": "^9.1.8",
59
- "@storybook/sveltekit": "^9.1.8",
58
+ "@storybook/svelte": "^9.1.10",
59
+ "@storybook/sveltekit": "^9.1.10",
60
60
  "@storybook/test": "^8.6.14",
61
- "@sveltejs/adapter-static": "^3.0.9",
62
- "@sveltejs/kit": "^2.43.5",
61
+ "@sveltejs/adapter-static": "^3.0.10",
62
+ "@sveltejs/kit": "^2.44.0",
63
63
  "@sveltejs/package": "^2.5.4",
64
64
  "@sveltejs/vite-plugin-svelte": "^6.2.1",
65
- "@tailwindcss/postcss": "^4.1.13",
65
+ "@tailwindcss/postcss": "^4.1.14",
66
66
  "@testing-library/svelte": "^5.2.8",
67
67
  "@vitest/browser": "^3.2.4",
68
68
  "changeset": "^0.2.6",
69
- "eslint": "^9.36.0",
69
+ "eslint": "^9.37.0",
70
70
  "eslint-config-prettier": "^10.1.8",
71
71
  "eslint-plugin-svelte": "^3.12.4",
72
72
  "globals": "^16.4.0",
@@ -75,14 +75,14 @@
75
75
  "prettier": "^3.6.2",
76
76
  "prettier-plugin-svelte": "^3.4.0",
77
77
  "prettier-plugin-tailwindcss": "^0.6.14",
78
- "publint": "^0.3.13",
79
- "storybook": "^9.1.8",
80
- "svelte": "^5.39.6",
78
+ "publint": "^0.3.14",
79
+ "storybook": "^9.1.10",
80
+ "svelte": "^5.39.9",
81
81
  "svelte-check": "^4.3.2",
82
- "tailwindcss": "^4.1.13",
83
- "typescript": "^5.9.2",
82
+ "tailwindcss": "^4.1.14",
83
+ "typescript": "^5.9.3",
84
84
  "typescript-eslint": "^8.45.0",
85
- "vite": "^7.1.7",
85
+ "vite": "^7.1.9",
86
86
  "vitest": "^3.2.4"
87
87
  },
88
88
  "dependencies": {