@joewinke/jatui 0.2.8 → 0.2.10
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/CLAUDE.md +2 -0
- package/dist/components/ContextMenu.svelte +53 -4
- package/dist/components/ContextMenu.svelte.d.ts +2 -0
- package/dist/components/ContextMenuTrigger.svelte +85 -0
- package/dist/components/ContextMenuTrigger.svelte.d.ts +41 -0
- package/dist/dnd/createDndBoard.js +25 -11
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -65,6 +65,8 @@ Then import:
|
|
|
65
65
|
| `Sparkline` | Inline mini chart | `data: number[]`, `width`, `height`, `color` |
|
|
66
66
|
| `LazyImage` | Image with lazy loading + placeholder | `src`, `alt`, `placeholder` |
|
|
67
67
|
| `HunkDiffView` | Side-by-side diff with line numbers, red/green backgrounds, strikethrough on removed lines, inline word-level highlights via the `diff` lib. Used for reviewing YAML/text changes per hunk. | `oldText: string \| null`, `newText: string \| null`, `unified?: boolean` |
|
|
68
|
+
| `ContextMenu` | Floating right-click (or programmatic) action menu, positioned via `use:floating` at a cursor point. Real `<button>`s, per-item `icon`/`variant`/`disabled`, dividers, click-outside + Escape dismiss, arrow-key/Home/End roving nav (focus starts on the first item). | `open` (bindable), `x`, `y`, `items: ContextMenuItem[]`, `header?`, `zIndex?`, `onClose?` |
|
|
69
|
+
| `ContextMenuTrigger` | Wraps content with the standard right-click + "⋯" touch affordance, backed by `ContextMenu`. Use instead of hand-rolling `oncontextmenu` + a kebab button per row/card. | `items: ContextMenuItem[]`, `children` (snippet), `header?`, `triggerLabel?`, `hideButton?`, `zIndex?`, `class?` |
|
|
68
70
|
|
|
69
71
|
### Media
|
|
70
72
|
|
|
@@ -21,18 +21,25 @@
|
|
|
21
21
|
* y={cm.y}
|
|
22
22
|
* header={cm.target?.name}
|
|
23
23
|
* items={[
|
|
24
|
-
* { label: "Preview", onSelect: () => preview(cm.target) },
|
|
24
|
+
* { label: "Preview", icon: "👁️", onSelect: () => preview(cm.target) },
|
|
25
25
|
* { label: "Copy URL", onSelect: () => copy(cm.target) },
|
|
26
26
|
* { divider: true },
|
|
27
27
|
* { label: "Delete", variant: "danger", onSelect: () => del(cm.target) },
|
|
28
28
|
* ]}
|
|
29
29
|
* />
|
|
30
|
+
*
|
|
31
|
+
* Keyboard: ArrowUp/ArrowDown/Home/End move focus among items, Escape
|
|
32
|
+
* dismisses (handled by use:floating), Enter/Space activates the focused
|
|
33
|
+
* button natively. Focus moves to the first item when the menu opens and
|
|
34
|
+
* returns to the trigger when it closes.
|
|
30
35
|
*/
|
|
31
36
|
|
|
32
37
|
import { floating, makeVirtualAnchor } from '../actions/floating.js';
|
|
33
38
|
|
|
34
39
|
type ActionItem = {
|
|
35
40
|
label: string;
|
|
41
|
+
/** Optional leading icon — a single emoji/glyph, rendered before the label. */
|
|
42
|
+
icon?: string;
|
|
36
43
|
onSelect: () => void;
|
|
37
44
|
variant?: 'default' | 'danger';
|
|
38
45
|
disabled?: boolean;
|
|
@@ -84,16 +91,57 @@
|
|
|
84
91
|
|
|
85
92
|
// Virtual anchor tracks the cursor position; rebuilt whenever x or y change.
|
|
86
93
|
let anchor = $derived(makeVirtualAnchor(x, y));
|
|
94
|
+
|
|
95
|
+
let menuEl = $state<HTMLDivElement | null>(null);
|
|
96
|
+
|
|
97
|
+
function menuItemButtons(): HTMLButtonElement[] {
|
|
98
|
+
if (!menuEl) return [];
|
|
99
|
+
return Array.from(menuEl.querySelectorAll('button[role="menuitem"]:not(:disabled)'));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
103
|
+
if (e.key === 'Escape') {
|
|
104
|
+
close();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
|
|
108
|
+
const buttons = menuItemButtons();
|
|
109
|
+
if (!buttons.length) return;
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
const current = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
|
112
|
+
let next: HTMLButtonElement;
|
|
113
|
+
if (e.key === 'ArrowDown') next = buttons[(current + 1 + buttons.length) % buttons.length];
|
|
114
|
+
else if (e.key === 'ArrowUp') next = buttons[(current - 1 + buttons.length) % buttons.length];
|
|
115
|
+
else if (e.key === 'Home') next = buttons[0];
|
|
116
|
+
else next = buttons[buttons.length - 1];
|
|
117
|
+
next.focus();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Move focus into the menu (first item) whenever it opens — the floating
|
|
121
|
+
// action's returnFocusTarget already captured the pre-open activeElement
|
|
122
|
+
// by the time this effect runs, so focus-return-on-close still works.
|
|
123
|
+
$effect(() => {
|
|
124
|
+
if (open && menuEl) {
|
|
125
|
+
const el = menuEl;
|
|
126
|
+
requestAnimationFrame(() => {
|
|
127
|
+
const buttons = Array.from(
|
|
128
|
+
el.querySelectorAll('button[role="menuitem"]:not(:disabled)')
|
|
129
|
+
) as HTMLButtonElement[];
|
|
130
|
+
buttons[0]?.focus();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
});
|
|
87
134
|
</script>
|
|
88
135
|
|
|
89
136
|
{#if open}
|
|
90
137
|
<div
|
|
138
|
+
bind:this={menuEl}
|
|
91
139
|
role="menu"
|
|
92
140
|
tabindex="-1"
|
|
93
141
|
class="min-w-[200px] bg-base-200 border border-base-300 rounded-md py-1 {className}"
|
|
94
142
|
style="z-index: {zIndex};"
|
|
95
143
|
use:floating={{ anchor, placement: 'bottom-start', flip: true, shift: true, offset: 2, onDismiss: close }}
|
|
96
|
-
onkeydown={
|
|
144
|
+
onkeydown={handleKeydown}
|
|
97
145
|
>
|
|
98
146
|
{#if header}
|
|
99
147
|
<p
|
|
@@ -110,13 +158,14 @@
|
|
|
110
158
|
<button
|
|
111
159
|
type="button"
|
|
112
160
|
role="menuitem"
|
|
113
|
-
class="w-full text-left px-3 py-1.5 text-sm transition-colors
|
|
161
|
+
class="w-full flex items-center gap-2 text-left px-3 py-1.5 text-sm transition-colors
|
|
114
162
|
{item.variant === 'danger' ? 'text-error hover:bg-error/10' : 'hover:bg-base-200'}
|
|
115
163
|
{item.disabled ? 'opacity-50 cursor-not-allowed' : ''}"
|
|
116
164
|
disabled={item.disabled}
|
|
117
165
|
onclick={() => handleSelect(item)}
|
|
118
166
|
>
|
|
119
|
-
{item.
|
|
167
|
+
{#if item.icon}<span class="shrink-0">{item.icon}</span>{/if}
|
|
168
|
+
<span class="truncate">{item.label}</span>
|
|
120
169
|
</button>
|
|
121
170
|
{/if}
|
|
122
171
|
{/each}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ContextMenuTrigger — wraps content with the standard right-click +
|
|
4
|
+
* "⋯" touch-affordance pattern, backed by ContextMenu.
|
|
5
|
+
*
|
|
6
|
+
* Right-click anywhere in the wrapped area opens the menu at the cursor.
|
|
7
|
+
* A small "⋯" button (visible on hover/focus for mouse, always visible on
|
|
8
|
+
* coarse/touch pointers) opens the same menu anchored to itself, so the
|
|
9
|
+
* action is reachable without a right-click gesture.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* <ContextMenuTrigger
|
|
13
|
+
* items={[
|
|
14
|
+
* { label: "Duplicate", icon: "📄", onSelect: () => duplicate(page) },
|
|
15
|
+
* { divider: true },
|
|
16
|
+
* { label: "Delete", variant: "danger", onSelect: () => del(page) },
|
|
17
|
+
* ]}
|
|
18
|
+
* >
|
|
19
|
+
* <PageCard {page} />
|
|
20
|
+
* </ContextMenuTrigger>
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { Snippet } from 'svelte';
|
|
24
|
+
import ContextMenu from './ContextMenu.svelte';
|
|
25
|
+
import type { ContextMenuItem } from './ContextMenu.svelte';
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
/** Ordered list of items and dividers to render in the menu. */
|
|
29
|
+
items: ContextMenuItem[];
|
|
30
|
+
/** Optional small italic label rendered above the items. */
|
|
31
|
+
header?: string;
|
|
32
|
+
/** Wrapped content. */
|
|
33
|
+
children: Snippet;
|
|
34
|
+
/** aria-label for the "⋯" touch-trigger button. Default "More actions". */
|
|
35
|
+
triggerLabel?: string;
|
|
36
|
+
/** Hide the "⋯" button — e.g. right-click-only contexts. Default false. */
|
|
37
|
+
hideButton?: boolean;
|
|
38
|
+
/** Stacking context z-index, forwarded to ContextMenu. Default 70. */
|
|
39
|
+
zIndex?: number;
|
|
40
|
+
/** Additional CSS class for the wrapper element. */
|
|
41
|
+
class?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let {
|
|
45
|
+
items,
|
|
46
|
+
header,
|
|
47
|
+
children,
|
|
48
|
+
triggerLabel = 'More actions',
|
|
49
|
+
hideButton = false,
|
|
50
|
+
zIndex = 70,
|
|
51
|
+
class: className = ''
|
|
52
|
+
}: Props = $props();
|
|
53
|
+
|
|
54
|
+
let cm = $state({ open: false, x: 0, y: 0 });
|
|
55
|
+
|
|
56
|
+
function openAt(x: number, y: number) {
|
|
57
|
+
cm = { open: true, x, y };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function handleContextMenu(e: MouseEvent) {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
openAt(e.clientX, e.clientY);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function handleTriggerClick(e: MouseEvent) {
|
|
66
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
67
|
+
openAt(rect.left, rect.bottom);
|
|
68
|
+
}
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<div class="group relative {className}" role="group" oncontextmenu={handleContextMenu}>
|
|
72
|
+
{@render children()}
|
|
73
|
+
{#if !hideButton}
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
class="btn btn-xs btn-circle bg-base-100/85 backdrop-blur-sm shadow-sm absolute top-1 right-1 z-10 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100"
|
|
77
|
+
aria-label={triggerLabel}
|
|
78
|
+
onclick={handleTriggerClick}
|
|
79
|
+
>
|
|
80
|
+
⋯
|
|
81
|
+
</button>
|
|
82
|
+
{/if}
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<ContextMenu bind:open={cm.open} x={cm.x} y={cm.y} {items} {header} {zIndex} />
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContextMenuTrigger — wraps content with the standard right-click +
|
|
3
|
+
* "⋯" touch-affordance pattern, backed by ContextMenu.
|
|
4
|
+
*
|
|
5
|
+
* Right-click anywhere in the wrapped area opens the menu at the cursor.
|
|
6
|
+
* A small "⋯" button (visible on hover/focus for mouse, always visible on
|
|
7
|
+
* coarse/touch pointers) opens the same menu anchored to itself, so the
|
|
8
|
+
* action is reachable without a right-click gesture.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* <ContextMenuTrigger
|
|
12
|
+
* items={[
|
|
13
|
+
* { label: "Duplicate", icon: "📄", onSelect: () => duplicate(page) },
|
|
14
|
+
* { divider: true },
|
|
15
|
+
* { label: "Delete", variant: "danger", onSelect: () => del(page) },
|
|
16
|
+
* ]}
|
|
17
|
+
* >
|
|
18
|
+
* <PageCard {page} />
|
|
19
|
+
* </ContextMenuTrigger>
|
|
20
|
+
*/
|
|
21
|
+
import type { Snippet } from 'svelte';
|
|
22
|
+
import type { ContextMenuItem } from './ContextMenu.svelte';
|
|
23
|
+
interface Props {
|
|
24
|
+
/** Ordered list of items and dividers to render in the menu. */
|
|
25
|
+
items: ContextMenuItem[];
|
|
26
|
+
/** Optional small italic label rendered above the items. */
|
|
27
|
+
header?: string;
|
|
28
|
+
/** Wrapped content. */
|
|
29
|
+
children: Snippet;
|
|
30
|
+
/** aria-label for the "⋯" touch-trigger button. Default "More actions". */
|
|
31
|
+
triggerLabel?: string;
|
|
32
|
+
/** Hide the "⋯" button — e.g. right-click-only contexts. Default false. */
|
|
33
|
+
hideButton?: boolean;
|
|
34
|
+
/** Stacking context z-index, forwarded to ContextMenu. Default 70. */
|
|
35
|
+
zIndex?: number;
|
|
36
|
+
/** Additional CSS class for the wrapper element. */
|
|
37
|
+
class?: string;
|
|
38
|
+
}
|
|
39
|
+
declare const ContextMenuTrigger: import("svelte").Component<Props, {}, "">;
|
|
40
|
+
type ContextMenuTrigger = ReturnType<typeof ContextMenuTrigger>;
|
|
41
|
+
export default ContextMenuTrigger;
|
|
@@ -45,17 +45,29 @@ export function createDndBoard(opts) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
48
|
+
// Seed synchronously at creation so the render mirror is populated for the very
|
|
49
|
+
// first render — SSR (where effects never run) and a client mount where items
|
|
50
|
+
// are already present. Without this the grid renders empty until an effect
|
|
51
|
+
// fires.
|
|
52
|
+
seed();
|
|
53
|
+
|
|
54
|
+
// Reactive re-seed on items() change — CLIENT ONLY. createDndBoard ships as a
|
|
55
|
+
// client build (svelte/internal/client); creating this effect during SSR calls
|
|
56
|
+
// user_effect with no active reaction → effect_orphan (a 500 on any page that
|
|
57
|
+
// server-renders a board). The synchronous seed above covers the SSR render.
|
|
58
|
+
if (typeof window !== 'undefined') {
|
|
59
|
+
$.user_effect(function () {
|
|
60
|
+
opts.items();
|
|
61
|
+
|
|
62
|
+
if ($.safe_get(dragging) || inFlight > 0) {
|
|
63
|
+
seedQueued = true;
|
|
64
|
+
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
56
67
|
|
|
57
|
-
|
|
58
|
-
|
|
68
|
+
seed();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
59
71
|
|
|
60
72
|
function placementFrom(dropTargets) {
|
|
61
73
|
var _a;
|
|
@@ -219,7 +231,9 @@ export function createDndBoard(opts) {
|
|
|
219
231
|
});
|
|
220
232
|
}
|
|
221
233
|
|
|
222
|
-
|
|
234
|
+
// Drag monitor — CLIENT ONLY (same effect_orphan reason as the seed effect;
|
|
235
|
+
// monitorForElements also needs the DOM, which does not exist during SSR).
|
|
236
|
+
if (typeof window !== 'undefined') $.user_effect(function () {
|
|
223
237
|
return monitorForElements({
|
|
224
238
|
canMonitor(_a) {
|
|
225
239
|
var source = _a.source;
|
package/dist/index.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type { InputOptions } from './stores/inputDialog';
|
|
|
23
23
|
export { default as SearchDropdown } from './components/SearchDropdown.svelte';
|
|
24
24
|
export { default as FilterDropdown } from './components/FilterDropdown.svelte';
|
|
25
25
|
export { default as ContextMenu } from './components/ContextMenu.svelte';
|
|
26
|
+
export { default as ContextMenuTrigger } from './components/ContextMenuTrigger.svelte';
|
|
26
27
|
export { default as ChipInput } from './components/ChipInput.svelte';
|
|
27
28
|
export { default as ResizableDivider } from './components/ResizableDivider.svelte';
|
|
28
29
|
export { default as DateRangePicker } from './components/DateRangePicker.svelte';
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@ export { showInput, submitInput, cancelInput, getPendingInput } from './stores/i
|
|
|
20
20
|
export { default as SearchDropdown } from './components/SearchDropdown.svelte';
|
|
21
21
|
export { default as FilterDropdown } from './components/FilterDropdown.svelte';
|
|
22
22
|
export { default as ContextMenu } from './components/ContextMenu.svelte';
|
|
23
|
+
export { default as ContextMenuTrigger } from './components/ContextMenuTrigger.svelte';
|
|
23
24
|
export { default as ChipInput } from './components/ChipInput.svelte';
|
|
24
25
|
export { default as ResizableDivider } from './components/ResizableDivider.svelte';
|
|
25
26
|
export { default as DateRangePicker } from './components/DateRangePicker.svelte';
|