@r2digisolutions/components 0.1.5 → 0.1.7

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.
Files changed (72) hide show
  1. package/README.md +6 -1
  2. package/dist/components/atoms/Checkbox/Checkbox.svelte +10 -3
  3. package/dist/components/atoms/Checkbox/Checkbox.svelte.d.ts +3 -0
  4. package/dist/components/atoms/Input/Input.svelte +17 -2
  5. package/dist/components/atoms/Input/Input.svelte.d.ts +6 -1
  6. package/dist/components/atoms/MarqueeRect/MarqueeRect.svelte +21 -0
  7. package/dist/components/atoms/MarqueeRect/MarqueeRect.svelte.d.ts +8 -0
  8. package/dist/components/atoms/Textarea/Textarea.svelte +4 -1
  9. package/dist/components/atoms/Textarea/Textarea.svelte.d.ts +1 -0
  10. package/dist/components/atoms/Toggle/Toggle.svelte +1 -1
  11. package/dist/components/molecules/BulkActionBar/BulkActionBar.svelte +19 -5
  12. package/dist/components/molecules/CodeEditor/CodeEditor.svelte +13 -10
  13. package/dist/components/molecules/ColorPicker/ColorPicker.svelte +166 -96
  14. package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanel.stories.d.ts +9 -0
  15. package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanel.stories.js +8 -0
  16. package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanel.svelte +412 -0
  17. package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanel.svelte.d.ts +14 -0
  18. package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanelStory.svelte +27 -0
  19. package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanelStory.svelte.d.ts +3 -0
  20. package/dist/components/molecules/ContextMenu/ContextMenu.svelte +73 -21
  21. package/dist/components/molecules/ContextMenu/ContextMenu.svelte.d.ts +8 -1
  22. package/dist/components/molecules/DateTimePicker/DateTimePicker.svelte.d.ts +1 -1
  23. package/dist/components/molecules/FormDateTimePicker/FormDateTimePicker.svelte.d.ts +1 -1
  24. package/dist/components/molecules/Gauge/Gauge.stories.d.ts +1 -1
  25. package/dist/components/molecules/InfiniteScroll/InfiniteScroll.stories.d.ts +1 -1
  26. package/dist/components/organisms/AssetPickerPage/AssetPickerPage.svelte.d.ts +1 -1
  27. package/dist/components/organisms/BacklogPage/BacklogPage.svelte.d.ts +1 -1
  28. package/dist/components/organisms/CalendarApp/CalendarApp.svelte.d.ts +1 -1
  29. package/dist/components/organisms/DataGrid/DataGrid.stories.d.ts +65 -0
  30. package/dist/components/organisms/DataGrid/DataGrid.stories.js +95 -0
  31. package/dist/components/organisms/DataGrid/DataGrid.svelte +2264 -0
  32. package/dist/components/organisms/DataGrid/DataGrid.svelte.d.ts +99 -0
  33. package/dist/components/organisms/DataGrid/DataGridStory.svelte +659 -0
  34. package/dist/components/organisms/DataGrid/DataGridStory.svelte.d.ts +11 -0
  35. package/dist/components/organisms/DataGrid/breakpoints.d.ts +10 -0
  36. package/dist/components/organisms/DataGrid/breakpoints.js +20 -0
  37. package/dist/components/organisms/DataGrid/cellEditors.d.ts +8 -0
  38. package/dist/components/organisms/DataGrid/cellEditors.js +59 -0
  39. package/dist/components/organisms/DataGrid/conditionalFormat.d.ts +11 -0
  40. package/dist/components/organisms/DataGrid/conditionalFormat.js +98 -0
  41. package/dist/components/organisms/DataGrid/filterRows.d.ts +5 -0
  42. package/dist/components/organisms/DataGrid/filterRows.js +60 -0
  43. package/dist/components/organisms/DataGrid/marks.d.ts +9 -0
  44. package/dist/components/organisms/DataGrid/marks.js +91 -0
  45. package/dist/components/organisms/DataGrid/types.d.ts +126 -0
  46. package/dist/components/organisms/DataGrid/types.js +21 -0
  47. package/dist/components/organisms/ResourceStudio/ResourceStudio.stories.d.ts +33 -0
  48. package/dist/components/organisms/ResourceStudio/ResourceStudio.stories.js +56 -0
  49. package/dist/components/organisms/ResourceStudio/ResourceStudio.svelte +830 -0
  50. package/dist/components/organisms/ResourceStudio/ResourceStudio.svelte.d.ts +68 -0
  51. package/dist/components/organisms/ResourceStudio/ResourceStudioStory.svelte +600 -0
  52. package/dist/components/organisms/ResourceStudio/ResourceStudioStory.svelte.d.ts +7 -0
  53. package/dist/components/organisms/ResourceStudio/fieldsToColumns.d.ts +4 -0
  54. package/dist/components/organisms/ResourceStudio/fieldsToColumns.js +20 -0
  55. package/dist/components/organisms/ResourceStudio/fieldsToSchema.d.ts +6 -0
  56. package/dist/components/organisms/ResourceStudio/fieldsToSchema.js +53 -0
  57. package/dist/components/organisms/ResourceStudio/types.d.ts +69 -0
  58. package/dist/components/organisms/ResourceStudio/types.js +15 -0
  59. package/dist/components/organisms/Scheduler/Scheduler.svelte.d.ts +1 -1
  60. package/dist/components/organisms/SchemaForm/SchemaForm.svelte +10 -3
  61. package/dist/components/organisms/SchemaForm/SchemaForm.svelte.d.ts +1 -0
  62. package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplate.stories.d.ts +25 -0
  63. package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplate.stories.js +18 -0
  64. package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplate.svelte +192 -0
  65. package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplate.svelte.d.ts +62 -0
  66. package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplateStory.svelte +129 -0
  67. package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplateStory.svelte.d.ts +7 -0
  68. package/dist/index.d.ts +11 -0
  69. package/dist/index.js +8 -0
  70. package/dist/utils/marqueeSelect.d.ts +48 -0
  71. package/dist/utils/marqueeSelect.js +189 -0
  72. package/package.json +1 -1
@@ -0,0 +1,412 @@
1
+ <script lang="ts">
2
+ import Button from '../../atoms/Button/Button.svelte';
3
+ import Badge from '../../atoms/Badge/Badge.svelte';
4
+ import IconButton from '../../atoms/IconButton/IconButton.svelte';
5
+ import Text from '../../atoms/Text/Text.svelte';
6
+ import Divider from '../../atoms/Divider/Divider.svelte';
7
+ import ColorSwatch from '../../atoms/ColorSwatch/ColorSwatch.svelte';
8
+ import Toggle from '../../atoms/Toggle/Toggle.svelte';
9
+ import Input from '../../atoms/Input/Input.svelte';
10
+ import Card from '../Card/Card.svelte';
11
+ import Select from '../Select/Select.svelte';
12
+ import ColorPicker from '../ColorPicker/ColorPicker.svelte';
13
+ import EmptyState from '../EmptyState/EmptyState.svelte';
14
+ import type {
15
+ ConditionalFormatRule,
16
+ FormatCondition,
17
+ FormatTarget
18
+ } from '../../organisms/DataGrid/types.js';
19
+ import ArrowUp from '@lucide/svelte/icons/arrow-up';
20
+ import ArrowDown from '@lucide/svelte/icons/arrow-down';
21
+ import Pencil from '@lucide/svelte/icons/pencil';
22
+ import Trash2 from '@lucide/svelte/icons/trash-2';
23
+ import Palette from '@lucide/svelte/icons/palette';
24
+
25
+ interface ColumnOption {
26
+ id: string;
27
+ label: string;
28
+ }
29
+
30
+ interface ConditionalFormatPanelProps {
31
+ rules?: ConditionalFormatRule[];
32
+ columns?: ColumnOption[];
33
+ class?: string;
34
+ onchange?: (rules: ConditionalFormatRule[]) => void;
35
+ }
36
+
37
+ let {
38
+ rules = $bindable<ConditionalFormatRule[]>([]),
39
+ columns = [],
40
+ class: className = '',
41
+ onchange
42
+ }: ConditionalFormatPanelProps = $props();
43
+
44
+ let editingId = $state<string | null>(null);
45
+ let draftName = $state('');
46
+ let draftTarget = $state<string>('row');
47
+ let draftColumnId = $state('');
48
+ let draftOp = $state<string>('eq');
49
+ let draftValue = $state('');
50
+ let draftMin = $state('0');
51
+ let draftMax = $state('100');
52
+ let draftBg = $state('#fef3c7');
53
+ let draftFg = $state('#92400e');
54
+
55
+ const isEditing = $derived(editingId !== null);
56
+
57
+ const targetOptions = [
58
+ { value: 'row', label: 'Entire row' },
59
+ { value: 'cell', label: 'Cell only' }
60
+ ];
61
+
62
+ const ops = [
63
+ { value: 'eq', label: 'Equals' },
64
+ { value: 'neq', label: 'Not equals' },
65
+ { value: 'gt', label: 'Greater than' },
66
+ { value: 'gte', label: '≥ Greater or equal' },
67
+ { value: 'lt', label: 'Less than' },
68
+ { value: 'lte', label: '≤ Less or equal' },
69
+ { value: 'contains', label: 'Contains' },
70
+ { value: 'startsWith', label: 'Starts with' },
71
+ { value: 'empty', label: 'Is empty' },
72
+ { value: 'notEmpty', label: 'Is not empty' },
73
+ { value: 'between', label: 'Between' }
74
+ ];
75
+
76
+ const columnOptions = $derived(columns.map((c) => ({ value: c.id, label: c.label })));
77
+
78
+ const formatSwatches = [
79
+ '#fef3c7',
80
+ '#92400e',
81
+ '#fee2e2',
82
+ '#991b1b',
83
+ '#dcfce7',
84
+ '#166534',
85
+ '#dbeafe',
86
+ '#1e40af',
87
+ '#f3e8ff',
88
+ '#6b21a8',
89
+ '#f1f5f9',
90
+ '#0f172a'
91
+ ];
92
+
93
+ $effect(() => {
94
+ if (!draftColumnId && columns[0]) draftColumnId = columns[0].id;
95
+ });
96
+
97
+ function emit(next: ConditionalFormatRule[]) {
98
+ rules = next;
99
+ onchange?.(next);
100
+ }
101
+
102
+ function buildWhen(): FormatCondition | null {
103
+ const columnId = draftColumnId || columns[0]?.id;
104
+ if (!columnId) return null;
105
+ const op = draftOp as FormatCondition['op'];
106
+ if (op === 'empty' || op === 'notEmpty') return { op, columnId };
107
+ if (op === 'between') {
108
+ return { op: 'between', columnId, min: Number(draftMin), max: Number(draftMax) };
109
+ }
110
+ if (op === 'contains' || op === 'startsWith') {
111
+ return { op, columnId, value: draftValue };
112
+ }
113
+ const num = Number(draftValue);
114
+ const value =
115
+ draftValue !== '' && !Number.isNaN(num) && String(num) === draftValue.trim()
116
+ ? num
117
+ : draftValue;
118
+ return { op: op as 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte', columnId, value };
119
+ }
120
+
121
+ function resetDraft() {
122
+ editingId = null;
123
+ draftName = '';
124
+ draftTarget = 'row';
125
+ draftOp = 'eq';
126
+ draftValue = '';
127
+ draftMin = '0';
128
+ draftMax = '100';
129
+ draftBg = '#fef3c7';
130
+ draftFg = '#92400e';
131
+ draftColumnId = columns[0]?.id ?? '';
132
+ }
133
+
134
+ function loadRule(rule: ConditionalFormatRule) {
135
+ editingId = rule.id;
136
+ draftName = rule.name ?? '';
137
+ draftTarget = rule.target;
138
+ draftBg = rule.style.background ?? '#fef3c7';
139
+ draftFg = rule.style.color ?? '#92400e';
140
+ const w = rule.when;
141
+ draftColumnId = w.columnId;
142
+ draftOp = w.op;
143
+ draftValue = '';
144
+ draftMin = '0';
145
+ draftMax = '100';
146
+ if (w.op === 'between') {
147
+ draftMin = String(w.min);
148
+ draftMax = String(w.max);
149
+ } else if (w.op !== 'empty' && w.op !== 'notEmpty' && 'value' in w) {
150
+ draftValue = String(w.value);
151
+ }
152
+ }
153
+
154
+ function saveRule() {
155
+ const when = buildWhen();
156
+ if (!when) return;
157
+ const target = draftTarget as FormatTarget;
158
+ const style = { background: draftBg, color: draftFg };
159
+
160
+ if (editingId) {
161
+ emit(
162
+ rules.map((r) =>
163
+ r.id === editingId
164
+ ? {
165
+ ...r,
166
+ name: draftName || undefined,
167
+ target,
168
+ columnId: target === 'cell' ? when.columnId : undefined,
169
+ when,
170
+ style: { ...r.style, ...style }
171
+ }
172
+ : r
173
+ )
174
+ );
175
+ resetDraft();
176
+ return;
177
+ }
178
+
179
+ const maxPriority = rules.reduce((m, r) => Math.max(m, r.priority), 0);
180
+ const rule: ConditionalFormatRule = {
181
+ id: `rule-${Math.random().toString(36).slice(2, 9)}`,
182
+ name: draftName || undefined,
183
+ enabled: true,
184
+ target,
185
+ columnId: target === 'cell' ? when.columnId : undefined,
186
+ when,
187
+ style,
188
+ priority: maxPriority + 1
189
+ };
190
+ emit([...rules, rule]);
191
+ resetDraft();
192
+ }
193
+
194
+ function setEnabled(id: string, enabled: boolean) {
195
+ emit(rules.map((r) => (r.id === id ? { ...r, enabled } : r)));
196
+ }
197
+
198
+ function remove(id: string) {
199
+ if (editingId === id) resetDraft();
200
+ emit(rules.filter((r) => r.id !== id));
201
+ }
202
+
203
+ function move(id: string, dir: -1 | 1) {
204
+ const idx = rules.findIndex((r) => r.id === id);
205
+ if (idx < 0) return;
206
+ const next = [...rules];
207
+ const swap = idx + dir;
208
+ if (swap < 0 || swap >= next.length) return;
209
+ [next[idx], next[swap]] = [next[swap], next[idx]];
210
+ emit(next.map((r, i) => ({ ...r, priority: i + 1 })));
211
+ }
212
+
213
+ function describe(rule: ConditionalFormatRule): string {
214
+ const w = rule.when;
215
+ const col = columns.find((c) => c.id === w.columnId)?.label ?? w.columnId;
216
+ if (w.op === 'empty') return `${col} is empty`;
217
+ if (w.op === 'notEmpty') return `${col} is not empty`;
218
+ if (w.op === 'between') return `${col} between ${w.min}–${w.max}`;
219
+ if ('value' in w) return `${col} ${w.op} ${String(w.value)}`;
220
+ return `${col} ${w.op}`;
221
+ }
222
+ </script>
223
+
224
+ <Card variant="elevated" padding="md" class={className}>
225
+ {#snippet header()}
226
+ <div class="flex items-start justify-between gap-3">
227
+ <div class="min-w-0 space-y-0.5">
228
+ <p class="text-sm font-semibold text-primary">Conditional formatting</p>
229
+ <Text size="xs" tone="muted">Paint rows or cells when a condition matches.</Text>
230
+ </div>
231
+ <Badge variant="secondary">{rules.length}</Badge>
232
+ </div>
233
+ {/snippet}
234
+
235
+ <div class="space-y-3">
236
+ {#if rules.length === 0}
237
+ <EmptyState
238
+ title="No rules yet"
239
+ description="Add a rule below to highlight matching rows or cells."
240
+ class="!py-6"
241
+ >
242
+ {#snippet icon()}
243
+ <Palette class="h-6 w-6" strokeWidth={1.75} />
244
+ {/snippet}
245
+ </EmptyState>
246
+ {:else}
247
+ <ul class="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
248
+ {#each rules as rule (rule.id)}
249
+ <li
250
+ class={[
251
+ 'flex items-center gap-3 px-3 py-2.5 transition-opacity',
252
+ !rule.enabled && 'opacity-55',
253
+ editingId === rule.id && 'bg-brand-500/5'
254
+ ]}
255
+ >
256
+ <span
257
+ class="h-10 w-1.5 shrink-0 rounded-full"
258
+ style={`background:${rule.style.background ?? '#94a3b8'}`}
259
+ aria-hidden="true"
260
+ ></span>
261
+
262
+ <div class="min-w-0 flex-1">
263
+ <div class="flex flex-wrap items-center gap-2">
264
+ <span class="truncate text-sm font-medium text-primary">
265
+ {rule.name ?? describe(rule)}
266
+ </span>
267
+ <Badge size="sm" variant={rule.target === 'row' ? 'info' : 'primary'}>
268
+ {rule.target === 'row' ? 'Row' : 'Cell'}
269
+ </Badge>
270
+ </div>
271
+ <div class="mt-1 flex flex-wrap items-center gap-2">
272
+ <span
273
+ class="inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium"
274
+ style={`background-color:${rule.style.background ?? 'transparent'};color:${rule.style.color ?? 'inherit'};font-weight:${rule.style.fontWeight ?? 'medium'}`}
275
+ >
276
+ Aa preview
277
+ </span>
278
+ <Text as="span" size="xs" tone="muted">{describe(rule)}</Text>
279
+ </div>
280
+ </div>
281
+
282
+ <div class="flex shrink-0 items-center gap-1">
283
+ <Toggle
284
+ size="sm"
285
+ checked={rule.enabled}
286
+ label=""
287
+ onchange={(checked) => setEnabled(rule.id, checked)}
288
+ />
289
+ <IconButton
290
+ size="xs"
291
+ variant="ghost"
292
+ label="Edit rule"
293
+ onclick={() => loadRule(rule)}
294
+ >
295
+ <Pencil class="h-3.5 w-3.5" strokeWidth={2} />
296
+ </IconButton>
297
+ <IconButton
298
+ size="xs"
299
+ variant="ghost"
300
+ label="Move up"
301
+ disabled={rules[0]?.id === rule.id}
302
+ onclick={() => move(rule.id, -1)}
303
+ >
304
+ <ArrowUp class="h-3.5 w-3.5" strokeWidth={2} />
305
+ </IconButton>
306
+ <IconButton
307
+ size="xs"
308
+ variant="ghost"
309
+ label="Move down"
310
+ disabled={rules.at(-1)?.id === rule.id}
311
+ onclick={() => move(rule.id, 1)}
312
+ >
313
+ <ArrowDown class="h-3.5 w-3.5" strokeWidth={2} />
314
+ </IconButton>
315
+ <IconButton
316
+ size="xs"
317
+ variant="ghost"
318
+ label="Delete rule"
319
+ onclick={() => remove(rule.id)}
320
+ >
321
+ <Trash2 class="h-3.5 w-3.5" strokeWidth={2} />
322
+ </IconButton>
323
+ </div>
324
+ </li>
325
+ {/each}
326
+ </ul>
327
+ {/if}
328
+ </div>
329
+
330
+ {#snippet footer()}
331
+ <div class="space-y-4">
332
+ <Divider label={isEditing ? 'Edit rule' : 'New rule'} />
333
+
334
+ <div class="grid gap-3 sm:grid-cols-2">
335
+ <Input
336
+ label="Name"
337
+ placeholder="Optional"
338
+ size="sm"
339
+ bind:value={draftName}
340
+ />
341
+ <Select
342
+ label="Target"
343
+ size="sm"
344
+ options={targetOptions}
345
+ bind:value={draftTarget}
346
+ />
347
+ <Select
348
+ label="Column"
349
+ size="sm"
350
+ options={columnOptions}
351
+ bind:value={draftColumnId}
352
+ placeholder="Select column"
353
+ />
354
+ <Select
355
+ label="Operator"
356
+ size="sm"
357
+ options={ops}
358
+ bind:value={draftOp}
359
+ />
360
+ {#if draftOp === 'between'}
361
+ <Input label="Min" size="sm" type="number" bind:value={draftMin} />
362
+ <Input label="Max" size="sm" type="number" bind:value={draftMax} />
363
+ {:else if draftOp !== 'empty' && draftOp !== 'notEmpty'}
364
+ <div class="sm:col-span-2">
365
+ <Input
366
+ label="Value"
367
+ size="sm"
368
+ placeholder="Compare value"
369
+ bind:value={draftValue}
370
+ />
371
+ </div>
372
+ {/if}
373
+ </div>
374
+
375
+ <div class="grid gap-3 sm:grid-cols-2">
376
+ <ColorPicker
377
+ label="Background"
378
+ bind:value={draftBg}
379
+ swatches={formatSwatches}
380
+ format="hex"
381
+ />
382
+ <ColorPicker
383
+ label="Text"
384
+ bind:value={draftFg}
385
+ swatches={formatSwatches}
386
+ format="hex"
387
+ />
388
+ </div>
389
+
390
+ <div class="flex items-center justify-between gap-3">
391
+ <div class="flex items-center gap-2">
392
+ <ColorSwatch color={draftBg} size="sm" label="Background preview" />
393
+ <ColorSwatch color={draftFg} size="sm" label="Text preview" />
394
+ <span
395
+ class="rounded-md px-2 py-1 text-xs font-medium"
396
+ style={`background-color:${draftBg};color:${draftFg}`}
397
+ >
398
+ Preview
399
+ </span>
400
+ </div>
401
+ <div class="flex items-center gap-2">
402
+ {#if isEditing}
403
+ <Button size="sm" variant="ghost" onclick={resetDraft}>Cancel</Button>
404
+ {/if}
405
+ <Button size="sm" variant="primary" onclick={saveRule}>
406
+ {isEditing ? 'Update rule' : 'Add rule'}
407
+ </Button>
408
+ </div>
409
+ </div>
410
+ </div>
411
+ {/snippet}
412
+ </Card>
@@ -0,0 +1,14 @@
1
+ import type { ConditionalFormatRule } from '../../organisms/DataGrid/types.js';
2
+ interface ColumnOption {
3
+ id: string;
4
+ label: string;
5
+ }
6
+ interface ConditionalFormatPanelProps {
7
+ rules?: ConditionalFormatRule[];
8
+ columns?: ColumnOption[];
9
+ class?: string;
10
+ onchange?: (rules: ConditionalFormatRule[]) => void;
11
+ }
12
+ declare const ConditionalFormatPanel: import("svelte").Component<ConditionalFormatPanelProps, {}, "rules">;
13
+ type ConditionalFormatPanel = ReturnType<typeof ConditionalFormatPanel>;
14
+ export default ConditionalFormatPanel;
@@ -0,0 +1,27 @@
1
+ <script lang="ts">
2
+ import ConditionalFormatPanel from './ConditionalFormatPanel.svelte';
3
+ import type { ConditionalFormatRule } from '../../organisms/DataGrid/types.js';
4
+
5
+ let rules = $state<ConditionalFormatRule[]>([
6
+ {
7
+ id: 'r1',
8
+ name: 'Overdue',
9
+ enabled: true,
10
+ target: 'row',
11
+ when: { op: 'eq', columnId: 'status', value: 'overdue' },
12
+ style: { background: '#fee2e2', color: '#991b1b' },
13
+ priority: 1
14
+ }
15
+ ]);
16
+ </script>
17
+
18
+ <div class="max-w-lg">
19
+ <ConditionalFormatPanel
20
+ bind:rules
21
+ columns={[
22
+ { id: 'status', label: 'Status' },
23
+ { id: 'amount', label: 'Amount' },
24
+ { id: 'name', label: 'Name' }
25
+ ]}
26
+ />
27
+ </div>
@@ -0,0 +1,3 @@
1
+ declare const ConditionalFormatPanelStory: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type ConditionalFormatPanelStory = ReturnType<typeof ConditionalFormatPanelStory>;
3
+ export default ConditionalFormatPanelStory;
@@ -10,9 +10,17 @@
10
10
  shortcut?: string;
11
11
  }
12
12
 
13
+ export interface ContextMenuAnchor {
14
+ x: number;
15
+ y: number;
16
+ }
17
+
13
18
  interface ContextMenuProps {
14
19
  items?: ContextMenuItem[];
15
20
  open?: boolean;
21
+ /** Screen coordinates for the menu (clientX / clientY). */
22
+ anchor?: ContextMenuAnchor | null;
23
+ class?: string;
16
24
  children?: Snippet;
17
25
  onselect?: (id: string, item: ContextMenuItem) => void;
18
26
  }
@@ -20,6 +28,8 @@
20
28
  let {
21
29
  items = [],
22
30
  open = $bindable(false),
31
+ anchor = $bindable<ContextMenuAnchor | null>(null),
32
+ class: className = '',
23
33
  children,
24
34
  onselect
25
35
  }: ContextMenuProps = $props();
@@ -27,18 +37,17 @@
27
37
  let menuX = $state(0);
28
38
  let menuY = $state(0);
29
39
  let menuEl = $state<HTMLDivElement | null>(null);
30
- let containerEl = $state<HTMLDivElement | null>(null);
31
40
  let highlighted = $state(-1);
41
+ /** Ignore the click/pointer that browsers fire right after `contextmenu`. */
42
+ let suppressOutsideUntil = $state(0);
32
43
 
33
44
  const enabledIndexes = $derived(
34
45
  items.map((item, i) => (item.separator || item.disabled ? -1 : i)).filter((i) => i !== -1)
35
46
  );
36
47
 
37
- function openAt(x: number, y: number) {
38
- open = true;
39
- highlighted = -1;
40
-
41
- // Defer to measure menu dimensions after render
48
+ function placeMenu(x: number, y: number) {
49
+ menuX = x;
50
+ menuY = y;
42
51
  requestAnimationFrame(() => {
43
52
  if (!menuEl) return;
44
53
  const vw = window.innerWidth;
@@ -47,14 +56,19 @@
47
56
  menuX = x + mw > vw - 8 ? Math.max(8, x - mw) : x;
48
57
  menuY = y + mh > vh - 8 ? Math.max(8, y - mh) : y;
49
58
  });
59
+ }
50
60
 
51
- // Optimistically position (will be corrected by rAF)
52
- menuX = x;
53
- menuY = y;
61
+ function openAt(x: number, y: number) {
62
+ anchor = { x, y };
63
+ open = true;
64
+ highlighted = -1;
65
+ suppressOutsideUntil = performance.now() + 500;
66
+ placeMenu(x, y);
54
67
  }
55
68
 
56
69
  function close() {
57
70
  open = false;
71
+ anchor = null;
58
72
  highlighted = -1;
59
73
  }
60
74
 
@@ -66,12 +80,27 @@
66
80
 
67
81
  function handleContextMenu(event: MouseEvent) {
68
82
  event.preventDefault();
83
+ event.stopPropagation();
69
84
  openAt(event.clientX, event.clientY);
70
85
  }
71
86
 
87
+ function isOutsideEvent(event: Event) {
88
+ if (!open) return false;
89
+ if (performance.now() < suppressOutsideUntil) return false;
90
+ const target = event.target as Node | null;
91
+ if (menuEl && target && menuEl.contains(target)) return false;
92
+ return true;
93
+ }
94
+
95
+ function handleOutsidePointer(event: PointerEvent) {
96
+ if (event.button === 2) return;
97
+ if (!isOutsideEvent(event)) return;
98
+ close();
99
+ }
100
+
72
101
  function handleOutsideClick(event: MouseEvent) {
73
- if (!open) return;
74
- if (menuEl && menuEl.contains(event.target as Node)) return;
102
+ if (event.button === 2) return;
103
+ if (!isOutsideEvent(event)) return;
75
104
  close();
76
105
  }
77
106
 
@@ -109,16 +138,32 @@
109
138
  highlighted = enabledIndexes[next];
110
139
  }
111
140
  }
141
+
142
+ /** Render menu in document.body so overflow/transform ancestors cannot clip it. */
143
+ function portal(node: HTMLElement) {
144
+ document.body.appendChild(node);
145
+ return {
146
+ destroy() {
147
+ node.remove();
148
+ }
149
+ };
150
+ }
151
+
152
+ $effect(() => {
153
+ if (!open || !anchor) return;
154
+ suppressOutsideUntil = performance.now() + 500;
155
+ placeMenu(anchor.x, anchor.y);
156
+ });
112
157
  </script>
113
158
 
114
- <svelte:window onclick={handleOutsideClick} onkeydown={handleKeydown} />
159
+ <svelte:window
160
+ onpointerdown={handleOutsidePointer}
161
+ onclick={handleOutsideClick}
162
+ onkeydown={handleKeydown}
163
+ />
115
164
 
116
165
  <!-- svelte-ignore a11y_no_static_element_interactions -->
117
- <div
118
- bind:this={containerEl}
119
- class="contents"
120
- oncontextmenu={handleContextMenu}
121
- >
166
+ <div class={['h-full min-h-0 w-full', className]} oncontextmenu={handleContextMenu}>
122
167
  {#if children}
123
168
  {@render children()}
124
169
  {/if}
@@ -126,12 +171,13 @@
126
171
 
127
172
  {#if open}
128
173
  <div
174
+ use:portal
129
175
  bind:this={menuEl}
130
176
  role="menu"
131
177
  tabindex={-1}
132
178
  aria-label="Context menu"
133
179
  style="position:fixed;top:{menuY}px;left:{menuX}px;z-index:9999;"
134
- class="min-w-44 rounded-xl border border-border bg-surface-elevated p-1.5 shadow-xl outline-none animate-in fade-in zoom-in-95 duration-100"
180
+ class="min-w-44 rounded-xl border border-border bg-surface-elevated p-1.5 shadow-xl outline-none"
135
181
  >
136
182
  {#each items as item, index (item.id)}
137
183
  {#if item.separator}
@@ -144,8 +190,12 @@
144
190
  tabindex={-1}
145
191
  aria-disabled={item.disabled || undefined}
146
192
  onclick={() => selectItem(item)}
147
- onpointerenter={() => { if (!item.disabled) highlighted = index; }}
148
- onpointerleave={() => { highlighted = -1; }}
193
+ onpointerenter={() => {
194
+ if (!item.disabled) highlighted = index;
195
+ }}
196
+ onpointerleave={() => {
197
+ highlighted = -1;
198
+ }}
149
199
  class={[
150
200
  'flex cursor-pointer items-center justify-between gap-4 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
151
201
  item.disabled && 'cursor-not-allowed opacity-40',
@@ -155,7 +205,9 @@
155
205
  >
156
206
  <span class="truncate">{item.label}</span>
157
207
  {#if item.shortcut}
158
- <kbd class="shrink-0 rounded border border-border bg-surface-overlay px-1.5 py-0.5 text-[10px] font-mono text-secondary">
208
+ <kbd
209
+ class="shrink-0 rounded border border-border bg-surface-overlay px-1.5 py-0.5 font-mono text-[10px] text-secondary"
210
+ >
159
211
  {item.shortcut}
160
212
  </kbd>
161
213
  {/if}
@@ -7,12 +7,19 @@ export interface ContextMenuItem {
7
7
  separator?: boolean;
8
8
  shortcut?: string;
9
9
  }
10
+ export interface ContextMenuAnchor {
11
+ x: number;
12
+ y: number;
13
+ }
10
14
  interface ContextMenuProps {
11
15
  items?: ContextMenuItem[];
12
16
  open?: boolean;
17
+ /** Screen coordinates for the menu (clientX / clientY). */
18
+ anchor?: ContextMenuAnchor | null;
19
+ class?: string;
13
20
  children?: Snippet;
14
21
  onselect?: (id: string, item: ContextMenuItem) => void;
15
22
  }
16
- declare const ContextMenu: import("svelte").Component<ContextMenuProps, {}, "open">;
23
+ declare const ContextMenu: import("svelte").Component<ContextMenuProps, {}, "open" | "anchor">;
17
24
  type ContextMenu = ReturnType<typeof ContextMenu>;
18
25
  export default ContextMenu;
@@ -25,6 +25,6 @@ interface DateTimePickerProps {
25
25
  value: string;
26
26
  }) => void;
27
27
  }
28
- declare const DateTimePicker: import("svelte").Component<DateTimePickerProps, {}, "value" | "time" | "date" | "open">;
28
+ declare const DateTimePicker: import("svelte").Component<DateTimePickerProps, {}, "date" | "value" | "time" | "open">;
29
29
  type DateTimePicker = ReturnType<typeof DateTimePicker>;
30
30
  export default DateTimePicker;
@@ -18,6 +18,6 @@ interface FormDateTimePickerProps {
18
18
  value: string;
19
19
  }) => void;
20
20
  }
21
- declare const FormDateTimePicker: import("svelte").Component<FormDateTimePickerProps, {}, "value" | "time" | "date">;
21
+ declare const FormDateTimePicker: import("svelte").Component<FormDateTimePickerProps, {}, "date" | "value" | "time">;
22
22
  type FormDateTimePicker = ReturnType<typeof FormDateTimePicker>;
23
23
  export default FormDateTimePicker;
@@ -2,7 +2,7 @@ import type { StoryObj } from '@storybook/svelte';
2
2
  declare const meta: {
3
3
  title: string;
4
4
  component: import("svelte").Component<{
5
- example?: "grid" | "storage" | "ring" | "score" | "horseshoe" | "cpu" | "health" | "battery";
5
+ example?: "storage" | "grid" | "ring" | "score" | "horseshoe" | "cpu" | "health" | "battery";
6
6
  value?: number;
7
7
  min?: number;
8
8
  max?: number;