@r2digisolutions/components 0.14.22 → 0.15.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.
@@ -2,12 +2,12 @@
2
2
  import type { Snippet } from 'svelte';
3
3
 
4
4
  export type IconBoxTone = 'brand' | 'neutral' | 'success' | 'warning' | 'error' | 'info';
5
- export type IconBoxSize = 'sm' | 'md' | 'lg';
5
+ export type IconBoxSize = 'sm' | 'md' | 'lg' | 'xl';
6
6
 
7
7
  interface IconBoxProps {
8
8
  tone?: IconBoxTone;
9
9
  size?: IconBoxSize;
10
- rounded?: 'lg' | 'xl' | 'full';
10
+ rounded?: 'lg' | 'xl' | '2xl' | 'full';
11
11
  class?: string;
12
12
  children?: Snippet;
13
13
  }
@@ -32,12 +32,14 @@
32
32
  const sizes: Record<IconBoxSize, string> = {
33
33
  sm: 'h-8 w-8 [&_svg]:h-3.5 [&_svg]:w-3.5',
34
34
  md: 'h-10 w-10 [&_svg]:h-4 [&_svg]:w-4',
35
- lg: 'h-12 w-12 [&_svg]:h-5 [&_svg]:w-5'
35
+ lg: 'h-12 w-12 [&_svg]:h-5 [&_svg]:w-5',
36
+ xl: 'h-16 w-16 [&_svg]:h-7 [&_svg]:w-7 sm:h-20 sm:w-20 sm:[&_svg]:h-8 sm:[&_svg]:w-8'
36
37
  };
37
38
 
38
- const radii: Record<'lg' | 'xl' | 'full', string> = {
39
+ const radii: Record<'lg' | 'xl' | '2xl' | 'full', string> = {
39
40
  lg: 'rounded-lg',
40
41
  xl: 'rounded-xl',
42
+ '2xl': 'rounded-2xl',
41
43
  full: 'rounded-full'
42
44
  };
43
45
  </script>
@@ -1,10 +1,10 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  export type IconBoxTone = 'brand' | 'neutral' | 'success' | 'warning' | 'error' | 'info';
3
- export type IconBoxSize = 'sm' | 'md' | 'lg';
3
+ export type IconBoxSize = 'sm' | 'md' | 'lg' | 'xl';
4
4
  interface IconBoxProps {
5
5
  tone?: IconBoxTone;
6
6
  size?: IconBoxSize;
7
- rounded?: 'lg' | 'xl' | 'full';
7
+ rounded?: 'lg' | 'xl' | '2xl' | 'full';
8
8
  class?: string;
9
9
  children?: Snippet;
10
10
  }
@@ -1,4 +1,7 @@
1
1
  <script lang="ts">
2
+ import PanZoomViewport from '../PanZoomViewport/PanZoomViewport.svelte';
3
+ import OrgChartTree from './OrgChartTree.svelte';
4
+
2
5
  export interface OrgNode {
3
6
  id: string;
4
7
  name: string;
@@ -12,106 +15,41 @@
12
15
  root?: OrgNode | null;
13
16
  class?: string;
14
17
  onselect?: (node: OrgNode) => void;
18
+ /** Pan + wheel zoom viewport */
19
+ interactive?: boolean;
20
+ viewportHeight?: string;
21
+ showControls?: boolean;
22
+ /** Refit viewport when filter / data changes */
23
+ contentKey?: string | number | null;
15
24
  }
16
25
 
17
- let { root = null, class: className = '', onselect }: OrgChartProps = $props();
18
-
19
- let selected = $state<string | null>(null);
20
-
21
- const CARD_W = 176;
22
-
23
- function initials(name: string) {
24
- return name
25
- .split(/\s+/)
26
- .slice(0, 2)
27
- .map((p) => p[0]?.toUpperCase() ?? '')
28
- .join('');
29
- }
30
-
31
- function primaryAvatar(node: OrgNode) {
32
- return node.avatarUrl ?? node.assignees?.[0]?.avatarUrl ?? null;
33
- }
34
-
35
- function primaryLabel(node: OrgNode) {
36
- if (node.assignees?.length === 1) return node.assignees[0].name;
37
- return null;
38
- }
26
+ let {
27
+ root = null,
28
+ class: className = '',
29
+ onselect,
30
+ interactive = true,
31
+ viewportHeight = 'min(70vh, 640px)',
32
+ showControls = true,
33
+ contentKey = null
34
+ }: OrgChartProps = $props();
35
+
36
+ const viewportKey = $derived(contentKey ?? root?.id ?? 'empty');
39
37
  </script>
40
38
 
41
- {#snippet nodeCard(node: OrgNode)}
42
- <button
43
- type="button"
44
- class={[
45
- 'flex w-full flex-col items-center gap-1.5 rounded-xl border bg-surface-elevated px-3 py-2.5 text-center shadow-sm transition-all',
46
- selected === node.id
47
- ? 'border-brand-500 ring-2 ring-brand-500/30'
48
- : 'border-border hover:border-brand-400'
49
- ]}
50
- onclick={() => {
51
- selected = node.id;
52
- onselect?.(node);
53
- }}
39
+ {#if interactive && root}
40
+ <PanZoomViewport
41
+ class={className}
42
+ height={viewportHeight}
43
+ {showControls}
44
+ contentKey={viewportKey}
45
+ initialFit
54
46
  >
55
- {#if primaryAvatar(node)}
56
- <img src={primaryAvatar(node)!} alt="" class="h-9 w-9 rounded-full object-cover" />
57
- {:else}
58
- <span
59
- class="flex h-9 w-9 items-center justify-center rounded-full bg-brand-500/15 text-xs font-semibold text-brand-600 dark:text-brand-300"
60
- >
61
- {initials(node.name)}
62
- </span>
63
- {/if}
64
- <span class="line-clamp-2 text-sm leading-snug font-semibold text-primary">{node.name}</span>
65
- {#if primaryLabel(node)}
66
- <span class="truncate text-xs text-secondary">{primaryLabel(node)}</span>
67
- {/if}
68
- {#if node.role}
69
- <span class="truncate text-[11px] text-muted">{node.role}</span>
70
- {/if}
71
- {#if node.assignees && node.assignees.length > 1}
72
- <span class="text-[10px] text-muted">+{node.assignees.length - 1} más</span>
73
- {/if}
74
- </button>
75
- {/snippet}
76
-
77
- {#snippet tree(node: OrgNode)}
78
- <div class="flex flex-col items-center">
79
- <div class="shrink-0" style:width="{CARD_W}px">
80
- {@render nodeCard(node)}
81
- </div>
82
- {#if node.children?.length}
83
- <div class="h-6 w-0.5 shrink-0 bg-border-strong" aria-hidden="true"></div>
84
- {#if node.children.length === 1}
85
- {@render tree(node.children[0])}
86
- {:else}
87
- <div
88
- class="relative inline-flex items-start justify-center gap-4"
89
- style:--siblings={node.children.length}
90
- >
91
- <div
92
- class="pointer-events-none absolute top-0 h-0.5 bg-border-strong"
93
- style:left="calc(50% / var(--siblings))"
94
- style:right="calc(50% / var(--siblings))"
95
- aria-hidden="true"
96
- ></div>
97
- {#each node.children as child (child.id)}
98
- <div class="flex shrink-0 flex-col items-center" style:min-width="{CARD_W}px">
99
- <div class="h-4 w-0.5 shrink-0 bg-border-strong" aria-hidden="true"></div>
100
- {@render tree(child)}
101
- </div>
102
- {/each}
103
- </div>
104
- {/if}
105
- {/if}
47
+ <OrgChartTree {root} {onselect} />
48
+ </PanZoomViewport>
49
+ {:else if root}
50
+ <div class={['overflow-x-auto py-4', className]} role="tree" aria-label="Organization chart">
51
+ <OrgChartTree {root} {onselect} />
106
52
  </div>
107
- {/snippet}
108
-
109
- <div class={['overflow-x-auto py-4', className]} role="tree" aria-label="Organization chart">
110
- {#if root}
111
- <div class="flex min-w-max justify-center px-4 pb-2">
112
- {@render tree(root)}
113
- </div>
114
- {:else}
115
- <p class="py-8 text-center text-sm text-muted">No organization data</p>
116
- {/if}
117
- </div>
53
+ {:else}
54
+ <p class={['py-8 text-center text-sm text-muted', className]}>No organization data</p>
55
+ {/if}
@@ -14,6 +14,12 @@ interface OrgChartProps {
14
14
  root?: OrgNode | null;
15
15
  class?: string;
16
16
  onselect?: (node: OrgNode) => void;
17
+ /** Pan + wheel zoom viewport */
18
+ interactive?: boolean;
19
+ viewportHeight?: string;
20
+ showControls?: boolean;
21
+ /** Refit viewport when filter / data changes */
22
+ contentKey?: string | number | null;
17
23
  }
18
24
  declare const OrgChart: import("svelte").Component<OrgChartProps, {}, "">;
19
25
  type OrgChart = ReturnType<typeof OrgChart>;
@@ -0,0 +1,130 @@
1
+ <script lang="ts">
2
+ import OrgChartNode from './OrgChartNode.svelte';
3
+ import { getPanZoomContext } from '../PanZoomViewport/panZoomContext.js';
4
+ import type { OrgNode } from './OrgChart.svelte';
5
+
6
+ interface OrgChartNodeProps {
7
+ node: OrgNode;
8
+ onselect?: (node: OrgNode) => void;
9
+ selectedId?: string | null;
10
+ }
11
+
12
+ let { node, onselect, selectedId = $bindable(null) }: OrgChartNodeProps = $props();
13
+
14
+ const panZoom = getPanZoomContext();
15
+ const CARD_W = 176;
16
+
17
+ let rowEl = $state<HTMLDivElement | null>(null);
18
+ let line = $state<{ left: number; width: number } | null>(null);
19
+
20
+ function initials(name: string) {
21
+ return name
22
+ .split(/\s+/)
23
+ .slice(0, 2)
24
+ .map((p) => p[0]?.toUpperCase() ?? '')
25
+ .join('');
26
+ }
27
+
28
+ function primaryAvatar(n: OrgNode) {
29
+ return n.avatarUrl ?? n.assignees?.[0]?.avatarUrl ?? null;
30
+ }
31
+
32
+ function primaryLabel(n: OrgNode) {
33
+ if (n.assignees?.length === 1) return n.assignees[0].name;
34
+ return null;
35
+ }
36
+
37
+ function selectNode(n: OrgNode) {
38
+ if (panZoom?.consumeClick()) return;
39
+ selectedId = n.id;
40
+ onselect?.(n);
41
+ }
42
+
43
+ function measureLine() {
44
+ const children = node.children ?? [];
45
+ if (!rowEl || children.length < 2) {
46
+ line = null;
47
+ return;
48
+ }
49
+ const cols = [...rowEl.querySelectorAll<HTMLElement>('[data-org-col]')];
50
+ if (cols.length < 2) {
51
+ line = null;
52
+ return;
53
+ }
54
+ const rowRect = rowEl.getBoundingClientRect();
55
+ const first = cols[0].getBoundingClientRect();
56
+ const last = cols[cols.length - 1].getBoundingClientRect();
57
+ const left = first.left + first.width / 2 - rowRect.left;
58
+ const right = last.left + last.width / 2 - rowRect.left;
59
+ line = { left, width: Math.max(0, right - left) };
60
+ }
61
+
62
+ $effect(() => {
63
+ node.children?.length;
64
+ if (!rowEl) return;
65
+ measureLine();
66
+ const ro = new ResizeObserver(() => measureLine());
67
+ ro.observe(rowEl);
68
+ for (const col of rowEl.querySelectorAll('[data-org-col]')) ro.observe(col);
69
+ return () => ro.disconnect();
70
+ });
71
+ </script>
72
+
73
+ <div class="flex flex-col items-center">
74
+ <div class="shrink-0" style:width="{CARD_W}px">
75
+ <button
76
+ type="button"
77
+ class={[
78
+ 'flex w-full flex-col items-center gap-1.5 rounded-xl border bg-surface-elevated px-3 py-2.5 text-center shadow-sm transition-all',
79
+ selectedId === node.id
80
+ ? 'border-brand-500 ring-2 ring-brand-500/30'
81
+ : 'border-border hover:border-brand-400'
82
+ ]}
83
+ onclick={() => selectNode(node)}
84
+ >
85
+ {#if primaryAvatar(node)}
86
+ <img src={primaryAvatar(node)!} alt="" class="h-9 w-9 rounded-full object-cover" />
87
+ {:else}
88
+ <span
89
+ class="flex h-9 w-9 items-center justify-center rounded-full bg-brand-500/15 text-xs font-semibold text-brand-600 dark:text-brand-300"
90
+ >
91
+ {initials(node.name)}
92
+ </span>
93
+ {/if}
94
+ <span class="line-clamp-2 text-sm leading-snug font-semibold text-primary">{node.name}</span>
95
+ {#if primaryLabel(node)}
96
+ <span class="truncate text-xs text-secondary">{primaryLabel(node)}</span>
97
+ {/if}
98
+ {#if node.role}
99
+ <span class="truncate text-[11px] text-muted">{node.role}</span>
100
+ {/if}
101
+ {#if node.assignees && node.assignees.length > 1}
102
+ <span class="text-[10px] text-muted">+{node.assignees.length - 1} más</span>
103
+ {/if}
104
+ </button>
105
+ </div>
106
+
107
+ {#if node.children?.length}
108
+ <div class="h-6 w-0.5 shrink-0 bg-border-strong" aria-hidden="true"></div>
109
+ {#if node.children.length === 1}
110
+ <OrgChartNode node={node.children[0]} {onselect} bind:selectedId />
111
+ {:else}
112
+ <div bind:this={rowEl} class="relative inline-flex items-start justify-center gap-4">
113
+ {#if line}
114
+ <div
115
+ class="pointer-events-none absolute top-0 h-0.5 bg-border-strong"
116
+ style:left="{line.left}px"
117
+ style:width="{line.width}px"
118
+ aria-hidden="true"
119
+ ></div>
120
+ {/if}
121
+ {#each node.children as child (child.id)}
122
+ <div data-org-col class="flex shrink-0 flex-col items-center" style:min-width="{CARD_W}px">
123
+ <div class="h-4 w-0.5 shrink-0 bg-border-strong" aria-hidden="true"></div>
124
+ <OrgChartNode node={child} {onselect} bind:selectedId />
125
+ </div>
126
+ {/each}
127
+ </div>
128
+ {/if}
129
+ {/if}
130
+ </div>
@@ -0,0 +1,10 @@
1
+ import OrgChartNode from './OrgChartNode.svelte';
2
+ import type { OrgNode } from './OrgChart.svelte';
3
+ interface OrgChartNodeProps {
4
+ node: OrgNode;
5
+ onselect?: (node: OrgNode) => void;
6
+ selectedId?: string | null;
7
+ }
8
+ declare const OrgChartNode: import("svelte").Component<OrgChartNodeProps, {}, "selectedId">;
9
+ type OrgChartNode = ReturnType<typeof OrgChartNode>;
10
+ export default OrgChartNode;
@@ -29,7 +29,7 @@
29
29
  };
30
30
  </script>
31
31
 
32
- <div class="rounded-2xl border border-border bg-surface-elevated p-4">
33
- <p class="mb-4 text-sm font-semibold text-primary">Organization</p>
34
- <OrgChart {root} />
32
+ <div class="rounded-2xl border border-border bg-surface-elevated p-2">
33
+ <p class="mb-2 px-2 text-sm font-semibold text-primary">Organization</p>
34
+ <OrgChart {root} viewportHeight="480px" />
35
35
  </div>
@@ -0,0 +1,16 @@
1
+ <script lang="ts">
2
+ import OrgChartNode from './OrgChartNode.svelte';
3
+ import type { OrgNode } from './OrgChart.svelte';
4
+
5
+ interface OrgChartTreeProps {
6
+ root: OrgNode;
7
+ onselect?: (node: OrgNode) => void;
8
+ }
9
+
10
+ let { root, onselect }: OrgChartTreeProps = $props();
11
+ let selectedId = $state<string | null>(null);
12
+ </script>
13
+
14
+ <div class="flex min-w-max justify-center px-4 py-4" role="tree" aria-label="Organization chart">
15
+ <OrgChartNode node={root} {onselect} bind:selectedId />
16
+ </div>
@@ -0,0 +1,8 @@
1
+ import type { OrgNode } from './OrgChart.svelte';
2
+ interface OrgChartTreeProps {
3
+ root: OrgNode;
4
+ onselect?: (node: OrgNode) => void;
5
+ }
6
+ declare const OrgChartTree: import("svelte").Component<OrgChartTreeProps, {}, "">;
7
+ type OrgChartTree = ReturnType<typeof OrgChartTree>;
8
+ export default OrgChartTree;
@@ -48,13 +48,31 @@
48
48
  const titleSize: Record<PageHeaderSize, string> = {
49
49
  sm: 'text-lg font-semibold',
50
50
  md: 'text-2xl font-semibold',
51
- lg: 'text-3xl font-bold'
51
+ lg: 'text-3xl font-bold tracking-tight'
52
52
  };
53
53
 
54
54
  const spacing: Record<PageHeaderSize, string> = {
55
55
  sm: 'space-y-2',
56
56
  md: 'space-y-3',
57
- lg: 'space-y-4'
57
+ lg: 'space-y-5'
58
+ };
59
+
60
+ const bodyGap: Record<PageHeaderSize, string> = {
61
+ sm: 'gap-2',
62
+ md: 'gap-3',
63
+ lg: 'gap-4'
64
+ };
65
+
66
+ const textStack: Record<PageHeaderSize, string> = {
67
+ sm: 'space-y-1',
68
+ md: 'space-y-1.5',
69
+ lg: 'space-y-2'
70
+ };
71
+
72
+ const descriptionClass: Record<PageHeaderSize, string> = {
73
+ sm: 'max-w-2xl text-sm leading-snug text-muted',
74
+ md: 'max-w-2xl text-sm leading-relaxed text-muted',
75
+ lg: 'max-w-3xl text-base leading-relaxed text-muted'
58
76
  };
59
77
 
60
78
  const resolvedSize = $derived<PageHeaderSize>(
@@ -75,15 +93,15 @@
75
93
  <Breadcrumb items={breadcrumbs} />
76
94
  {/if}
77
95
 
78
- <div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
79
- <div class="flex min-w-0 items-center gap-2">
96
+ <div class={['flex flex-col sm:flex-row sm:items-start sm:justify-between', bodyGap[resolvedSize]]}>
97
+ <div class={['flex min-w-0 items-start', bodyGap[resolvedSize]]}>
80
98
  {#if leading}
81
- <div class="shrink-0">
99
+ <div class="shrink-0 pt-0.5">
82
100
  {@render leading()}
83
101
  </div>
84
102
  {/if}
85
103
 
86
- <div class="min-w-0 space-y-0.5">
104
+ <div class={['min-w-0', textStack[resolvedSize]]}>
87
105
  {#if loading}
88
106
  <Skeleton
89
107
  variant="rounded"
@@ -94,8 +112,8 @@
94
112
  <Skeleton variant="rounded" width="18rem" height="1rem" class="mt-1.5" />
95
113
  {/if}
96
114
  {:else}
97
- <div class="flex min-w-0 items-center gap-2">
98
- <h1 class={['truncate tracking-tight text-primary', titleSize[resolvedSize]]}>
115
+ <div class="flex min-w-0 flex-wrap items-center gap-2.5">
116
+ <h1 class={['min-w-0 text-primary', titleSize[resolvedSize]]}>
99
117
  {title}
100
118
  </h1>
101
119
  {#if status}
@@ -105,10 +123,10 @@
105
123
  {/if}
106
124
  </div>
107
125
  {#if description}
108
- <p class="max-w-2xl text-sm leading-snug text-muted">{description}</p>
126
+ <p class={descriptionClass[resolvedSize]}>{description}</p>
109
127
  {/if}
110
128
  {#if meta}
111
- <div class="pt-1">
129
+ <div class={resolvedSize === 'lg' ? 'pt-1' : 'pt-0.5'}>
112
130
  {@render meta()}
113
131
  </div>
114
132
  {/if}
@@ -117,7 +135,7 @@
117
135
  </div>
118
136
 
119
137
  {#if actions && !loading}
120
- <div class="flex shrink-0 flex-wrap items-center gap-2">
138
+ <div class="flex shrink-0 flex-wrap items-center gap-2 sm:pt-1">
121
139
  {@render actions()}
122
140
  </div>
123
141
  {:else if loading}
@@ -0,0 +1,8 @@
1
+ import PanZoomViewportStory from './PanZoomViewportStory.svelte';
2
+ const meta = {
3
+ title: 'Molecules/PanZoomViewport',
4
+ component: PanZoomViewportStory,
5
+ tags: ['autodocs']
6
+ };
7
+ export default meta;
8
+ export const Default = {};
@@ -0,0 +1,231 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { setContext } from 'svelte';
4
+ import IconButton from '../../atoms/IconButton/IconButton.svelte';
5
+ import FullscreenToggle from '../FullscreenToggle/FullscreenToggle.svelte';
6
+ import { PAN_ZOOM_CTX, type PanZoomContext } from './panZoomContext.js';
7
+
8
+ interface PanZoomViewportProps {
9
+ minZoom?: number;
10
+ maxZoom?: number;
11
+ initialFit?: boolean;
12
+ showControls?: boolean;
13
+ showFullscreen?: boolean;
14
+ height?: string;
15
+ contentKey?: string | number | null;
16
+ class?: string;
17
+ children?: Snippet;
18
+ }
19
+
20
+ let {
21
+ minZoom = 0.25,
22
+ maxZoom = 2,
23
+ initialFit = true,
24
+ showControls = true,
25
+ showFullscreen = true,
26
+ height = 'min(70vh, 640px)',
27
+ contentKey = null,
28
+ class: className = '',
29
+ children
30
+ }: PanZoomViewportProps = $props();
31
+
32
+ let scale = $state(1);
33
+ let panX = $state(0);
34
+ let panY = $state(0);
35
+ let dragging = $state(false);
36
+ let moved = $state(false);
37
+ let suppressClick = $state(false);
38
+ let pointerId = $state<number | null>(null);
39
+ let startX = 0;
40
+ let startY = 0;
41
+ let originPanX = 0;
42
+ let originPanY = 0;
43
+ let fullscreen = $state(false);
44
+
45
+ let shellEl = $state<HTMLDivElement | null>(null);
46
+ let viewportEl = $state<HTMLDivElement | null>(null);
47
+ let contentEl = $state<HTMLDivElement | null>(null);
48
+
49
+ const MOVE_THRESHOLD = 6;
50
+ const FIT_PADDING = 32;
51
+
52
+ const shellHeight = $derived(fullscreen ? '100%' : height);
53
+
54
+ function clampZoom(z: number) {
55
+ return Math.min(maxZoom, Math.max(minZoom, z));
56
+ }
57
+
58
+ export function fitToContent() {
59
+ if (!viewportEl || !contentEl) return;
60
+ const vw = viewportEl.clientWidth;
61
+ const vh = viewportEl.clientHeight;
62
+ const cw = contentEl.scrollWidth;
63
+ const ch = contentEl.scrollHeight;
64
+ if (!vw || !vh || !cw || !ch) return;
65
+
66
+ const next = clampZoom(Math.min((vw - FIT_PADDING) / cw, (vh - FIT_PADDING) / ch, 1));
67
+ scale = next;
68
+ panX = (vw - cw * next) / 2;
69
+ panY = (vh - ch * next) / 2;
70
+ }
71
+
72
+ function zoomAt(nextScale: number, clientX?: number, clientY?: number) {
73
+ if (!viewportEl) return;
74
+ const prev = scale;
75
+ const next = clampZoom(nextScale);
76
+ if (next === prev) return;
77
+
78
+ const rect = viewportEl.getBoundingClientRect();
79
+ const px = clientX != null ? clientX - rect.left : rect.width / 2;
80
+ const py = clientY != null ? clientY - rect.top : rect.height / 2;
81
+
82
+ panX = px - ((px - panX) * next) / prev;
83
+ panY = py - ((py - panY) * next) / prev;
84
+ scale = next;
85
+ }
86
+
87
+ function zoomIn() {
88
+ zoomAt(scale * 1.2);
89
+ }
90
+
91
+ function zoomOut() {
92
+ zoomAt(scale / 1.2);
93
+ }
94
+
95
+ function onWheel(e: WheelEvent) {
96
+ e.preventDefault();
97
+ const delta = e.deltaY > 0 ? -0.12 : 0.12;
98
+ zoomAt(scale + delta * scale, e.clientX, e.clientY);
99
+ }
100
+
101
+ function onPointerDown(e: PointerEvent) {
102
+ if (e.button !== 0) return;
103
+ if ((e.target as HTMLElement).closest('[data-panzoom-chrome]')) return;
104
+
105
+ dragging = true;
106
+ moved = false;
107
+ pointerId = e.pointerId;
108
+ startX = e.clientX;
109
+ startY = e.clientY;
110
+ originPanX = panX;
111
+ originPanY = panY;
112
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
113
+ }
114
+
115
+ function onPointerMove(e: PointerEvent) {
116
+ if (!dragging || pointerId !== e.pointerId) return;
117
+ const dx = e.clientX - startX;
118
+ const dy = e.clientY - startY;
119
+ if (!moved && Math.hypot(dx, dy) >= MOVE_THRESHOLD) moved = true;
120
+ panX = originPanX + dx;
121
+ panY = originPanY + dy;
122
+ }
123
+
124
+ function onPointerUp(e: PointerEvent) {
125
+ if (!dragging || pointerId !== e.pointerId) return;
126
+ dragging = false;
127
+ pointerId = null;
128
+ try {
129
+ (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
130
+ } catch {
131
+ /* already released */
132
+ }
133
+ if (moved) suppressClick = true;
134
+ moved = false;
135
+ }
136
+
137
+ function onDoubleClick(e: MouseEvent) {
138
+ if ((e.target as HTMLElement).closest('[data-panzoom-chrome]')) return;
139
+ fitToContent();
140
+ }
141
+
142
+ function onFullscreenChange(active: boolean) {
143
+ fullscreen = active;
144
+ requestAnimationFrame(() => fitToContent());
145
+ }
146
+
147
+ const ctx: PanZoomContext = {
148
+ consumeClick: () => {
149
+ if (!suppressClick) return false;
150
+ suppressClick = false;
151
+ return true;
152
+ }
153
+ };
154
+
155
+ setContext(PAN_ZOOM_CTX, ctx);
156
+
157
+ $effect(() => {
158
+ contentKey;
159
+ if (!initialFit) return;
160
+ const id = requestAnimationFrame(() => {
161
+ requestAnimationFrame(() => fitToContent());
162
+ });
163
+ return () => cancelAnimationFrame(id);
164
+ });
165
+ </script>
166
+
167
+ <div
168
+ bind:this={shellEl}
169
+ class={[
170
+ 'relative overflow-hidden rounded-xl border border-border bg-surface-base/40',
171
+ fullscreen && 'rounded-none border-0',
172
+ className
173
+ ]}
174
+ style:height={shellHeight}
175
+ >
176
+ {#if showControls}
177
+ <div
178
+ class="absolute top-3 right-3 z-10 flex items-center gap-1 rounded-lg border border-border bg-surface-elevated/95 p-1 shadow-sm backdrop-blur-sm"
179
+ data-panzoom-chrome
180
+ >
181
+ <IconButton label="Alejar" size="sm" variant="ghost" onclick={zoomOut}>
182
+ <span class="text-base leading-none font-medium">−</span>
183
+ </IconButton>
184
+ <span class="min-w-[3rem] px-1 text-center text-xs tabular-nums text-secondary">
185
+ {Math.round(scale * 100)}%
186
+ </span>
187
+ <IconButton label="Acercar" size="sm" variant="ghost" onclick={zoomIn}>
188
+ <span class="text-base leading-none font-medium">+</span>
189
+ </IconButton>
190
+ <IconButton label="Ajustar a vista" size="sm" variant="ghost" onclick={fitToContent}>
191
+ <span class="text-xs font-semibold">⊡</span>
192
+ </IconButton>
193
+ {#if showFullscreen}
194
+ <FullscreenToggle
195
+ target={shellEl}
196
+ size="sm"
197
+ bind:active={fullscreen}
198
+ enterLabel="Pantalla completa"
199
+ exitLabel="Salir de pantalla completa"
200
+ onchange={onFullscreenChange}
201
+ />
202
+ {/if}
203
+ </div>
204
+ {/if}
205
+
206
+ <div
207
+ bind:this={viewportEl}
208
+ class={[
209
+ 'h-full w-full touch-none select-none',
210
+ dragging ? 'cursor-grabbing' : 'cursor-grab'
211
+ ]}
212
+ onwheel={onWheel}
213
+ onpointerdown={onPointerDown}
214
+ onpointermove={onPointerMove}
215
+ onpointerup={onPointerUp}
216
+ onpointercancel={onPointerUp}
217
+ ondblclick={onDoubleClick}
218
+ role="presentation"
219
+ >
220
+ <div
221
+ bind:this={contentEl}
222
+ class="inline-block will-change-transform"
223
+ style:transform="translate({panX}px, {panY}px) scale({scale})"
224
+ style:transform-origin="0 0"
225
+ >
226
+ {#if children}
227
+ {@render children()}
228
+ {/if}
229
+ </div>
230
+ </div>
231
+ </div>
@@ -0,0 +1,17 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface PanZoomViewportProps {
3
+ minZoom?: number;
4
+ maxZoom?: number;
5
+ initialFit?: boolean;
6
+ showControls?: boolean;
7
+ showFullscreen?: boolean;
8
+ height?: string;
9
+ contentKey?: string | number | null;
10
+ class?: string;
11
+ children?: Snippet;
12
+ }
13
+ declare const PanZoomViewport: import("svelte").Component<PanZoomViewportProps, {
14
+ fitToContent: () => void;
15
+ }, "">;
16
+ type PanZoomViewport = ReturnType<typeof PanZoomViewport>;
17
+ export default PanZoomViewport;
@@ -0,0 +1,20 @@
1
+ <script lang="ts">
2
+ import PanZoomViewport from './PanZoomViewport.svelte';
3
+ </script>
4
+
5
+ <PanZoomViewport height="420px">
6
+ <div class="flex min-w-max flex-col items-center gap-8 p-12">
7
+ <div class="rounded-xl border border-border bg-surface-elevated px-6 py-4 text-sm font-semibold">
8
+ Arrastra para mover · rueda para zoom · doble clic para ajustar
9
+ </div>
10
+ <div class="flex gap-16">
11
+ {#each Array.from({ length: 8 }) as _, i (i)}
12
+ <div
13
+ class="flex h-24 w-40 items-center justify-center rounded-xl border border-border bg-surface-elevated text-sm"
14
+ >
15
+ Bloque {i + 1}
16
+ </div>
17
+ {/each}
18
+ </div>
19
+ </div>
20
+ </PanZoomViewport>
@@ -0,0 +1,18 @@
1
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
+ $$bindings?: Bindings;
4
+ } & Exports;
5
+ (internal: unknown, props: {
6
+ $$events?: Events;
7
+ $$slots?: Slots;
8
+ }): Exports & {
9
+ $set?: any;
10
+ $on?: any;
11
+ };
12
+ z_$$bindings?: Bindings;
13
+ }
14
+ declare const PanZoomViewportStory: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
+ [evt: string]: CustomEvent<any>;
16
+ }, {}, {}, string>;
17
+ type PanZoomViewportStory = InstanceType<typeof PanZoomViewportStory>;
18
+ export default PanZoomViewportStory;
@@ -0,0 +1,5 @@
1
+ export declare const PAN_ZOOM_CTX: unique symbol;
2
+ export type PanZoomContext = {
3
+ consumeClick: () => boolean;
4
+ };
5
+ export declare function getPanZoomContext(): PanZoomContext | undefined;
@@ -0,0 +1,5 @@
1
+ import { getContext } from 'svelte';
2
+ export const PAN_ZOOM_CTX = Symbol('panZoom');
3
+ export function getPanZoomContext() {
4
+ return getContext(PAN_ZOOM_CTX);
5
+ }
package/dist/index.d.ts CHANGED
@@ -252,6 +252,8 @@ export { default as VirtualList } from './components/molecules/VirtualList/Virtu
252
252
  export { default as MasonryGrid } from './components/molecules/MasonryGrid/MasonryGrid.svelte';
253
253
  export { default as OrgChart } from './components/molecules/OrgChart/OrgChart.svelte';
254
254
  export type { OrgNode } from './components/molecules/OrgChart/OrgChart.svelte';
255
+ export { default as PanZoomViewport } from './components/molecules/PanZoomViewport/PanZoomViewport.svelte';
256
+ export { getPanZoomContext, PAN_ZOOM_CTX, type PanZoomContext } from './components/molecules/PanZoomViewport/panZoomContext.js';
255
257
  export { default as TableToolbar } from './components/molecules/TableToolbar/TableToolbar.svelte';
256
258
  export type { TableColumnOption, TableDensity } from './components/molecules/TableToolbar/TableToolbar.svelte';
257
259
  export { default as ButtonGroup } from './components/molecules/ButtonGroup/ButtonGroup.svelte';
package/dist/index.js CHANGED
@@ -194,6 +194,8 @@ export { default as MessageList } from './components/molecules/MessageList/Messa
194
194
  export { default as VirtualList } from './components/molecules/VirtualList/VirtualList.svelte';
195
195
  export { default as MasonryGrid } from './components/molecules/MasonryGrid/MasonryGrid.svelte';
196
196
  export { default as OrgChart } from './components/molecules/OrgChart/OrgChart.svelte';
197
+ export { default as PanZoomViewport } from './components/molecules/PanZoomViewport/PanZoomViewport.svelte';
198
+ export { getPanZoomContext, PAN_ZOOM_CTX } from './components/molecules/PanZoomViewport/panZoomContext.js';
197
199
  export { default as TableToolbar } from './components/molecules/TableToolbar/TableToolbar.svelte';
198
200
  export { default as ButtonGroup } from './components/molecules/ButtonGroup/ButtonGroup.svelte';
199
201
  export { default as ThemeToggle } from './components/molecules/ThemeToggle/ThemeToggle.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/components",
3
- "version": "0.14.22",
3
+ "version": "0.15.1",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",