svelte-p5-components 0.4.0 → 0.5.0

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.
@@ -41,6 +41,13 @@
41
41
  onToggle?: (id: string, visible: boolean) => void;
42
42
  /** When provided, a native color input is shown and clicking the swatch opens it. */
43
43
  onColorChange?: (id: string, color: string) => void;
44
+ /**
45
+ * When provided, each item gets a pencil button (shown on hover) that
46
+ * enters inline-edit mode. Enter/blur commits via this callback;
47
+ * Escape cancels. Called with the entity's current id and the new
48
+ * label — the consumer is responsible for updating its own state.
49
+ */
50
+ onRename?: (id: string, nextLabel: string) => void;
44
51
  /** Max entities to show before collapsing the remainder behind a "+N more" expander. */
45
52
  maxVisible?: number;
46
53
  /** Optional heading above the list. */
@@ -52,12 +59,15 @@
52
59
  entities,
53
60
  onToggle,
54
61
  onColorChange,
62
+ onRename,
55
63
  maxVisible,
56
64
  heading,
57
65
  class: className = ''
58
66
  }: Props = $props();
59
67
 
60
68
  let expanded = $state(false);
69
+ let editingId = $state<string | null>(null);
70
+ let editValue = $state('');
61
71
 
62
72
  const visibleEntities = $derived(
63
73
  expanded || maxVisible === undefined ? entities : entities.slice(0, maxVisible)
@@ -94,6 +104,43 @@
94
104
  const target = event.currentTarget as HTMLInputElement;
95
105
  onColorChange?.(e.id, target.value);
96
106
  }
107
+
108
+ function startRename(e: Entity) {
109
+ editingId = e.id;
110
+ editValue = e.label;
111
+ }
112
+
113
+ function commitRename() {
114
+ if (editingId === null) return;
115
+ const id = editingId;
116
+ const next = editValue.trim();
117
+ editingId = null;
118
+ editValue = '';
119
+ const entity = entities.find((e) => e.id === id);
120
+ if (!entity) return;
121
+ if (next.length === 0 || next === entity.label) return;
122
+ onRename?.(id, next);
123
+ }
124
+
125
+ function cancelRename() {
126
+ editingId = null;
127
+ editValue = '';
128
+ }
129
+
130
+ function handleEditKeydown(event: KeyboardEvent) {
131
+ if (event.key === 'Enter') {
132
+ event.preventDefault();
133
+ commitRename();
134
+ } else if (event.key === 'Escape') {
135
+ event.preventDefault();
136
+ cancelRename();
137
+ }
138
+ }
139
+
140
+ function focusOnMount(node: HTMLInputElement) {
141
+ node.focus();
142
+ node.select();
143
+ }
97
144
  </script>
98
145
 
99
146
  <div class="entity-toggle-list {className}" data-has-groups={hasGroupBoundaries}>
@@ -128,15 +175,43 @@
128
175
  aria-hidden="true"
129
176
  ></span>
130
177
  {/if}
131
- <button
132
- type="button"
133
- class="entity-toggle-list__label"
134
- aria-pressed={isVisible(entity)}
135
- title={isVisible(entity) ? `Hide ${entity.label}` : `Show ${entity.label}`}
136
- onclick={() => handleToggle(entity)}
137
- >
138
- {entity.label}
139
- </button>
178
+ {#if editingId === entity.id}
179
+ <input
180
+ class="entity-toggle-list__edit-input"
181
+ type="text"
182
+ bind:value={editValue}
183
+ onkeydown={handleEditKeydown}
184
+ onblur={commitRename}
185
+ aria-label="Rename {entity.label}"
186
+ use:focusOnMount
187
+ />
188
+ {:else}
189
+ <button
190
+ type="button"
191
+ class="entity-toggle-list__label"
192
+ aria-pressed={isVisible(entity)}
193
+ title={isVisible(entity) ? `Hide ${entity.label}` : `Show ${entity.label}`}
194
+ onclick={() => handleToggle(entity)}
195
+ >
196
+ {entity.label}
197
+ </button>
198
+ {/if}
199
+ {#if onRename && editingId !== entity.id}
200
+ <button
201
+ type="button"
202
+ class="entity-toggle-list__rename"
203
+ aria-label="Rename {entity.label}"
204
+ title="Rename"
205
+ onclick={() => startRename(entity)}
206
+ >
207
+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
208
+ <path
209
+ d="M14.06 9 15 9.94 5.92 19H5v-.92L14.06 9m3.6-6c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29Zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94z"
210
+ fill="currentColor"
211
+ />
212
+ </svg>
213
+ </button>
214
+ {/if}
140
215
  </div>
141
216
  {/each}
142
217
  </div>
@@ -169,17 +244,27 @@
169
244
  flex-direction: column;
170
245
  gap: 6px;
171
246
  font:
172
- 12px/1.3 system-ui,
247
+ 13px/1.3 system-ui,
173
248
  -apple-system,
174
249
  Segoe UI,
175
250
  sans-serif;
176
251
 
177
- --entity-swatch-size: 12px;
252
+ /* Defaults derive from currentColor so the list inherits the
253
+ * embedding chrome's theme. Consumers can still override any
254
+ * of these individually. */
255
+ --entity-swatch-size: 14px;
178
256
  --entity-btn-bg: transparent;
179
- --entity-btn-bg-hover: rgba(0, 0, 0, 0.05);
180
- --entity-btn-fg: #111;
181
- --entity-btn-fg-hidden: #9ca3af;
182
- --entity-heading-fg: #6b7280;
257
+ --entity-btn-bg-hover: color-mix(in srgb, currentColor 8%, transparent);
258
+ --entity-btn-border-hover: color-mix(in srgb, currentColor 12%, transparent);
259
+ --entity-btn-fg: currentColor;
260
+ --entity-btn-fg-hidden: color-mix(in srgb, currentColor 45%, transparent);
261
+ --entity-heading-fg: color-mix(in srgb, currentColor 65%, transparent);
262
+ --entity-swatch-border: color-mix(in srgb, currentColor 25%, transparent);
263
+ --entity-swatch-inset: color-mix(in srgb, currentColor 15%, transparent);
264
+ --entity-edit-bg: color-mix(in srgb, currentColor 4%, transparent);
265
+ --entity-edit-border: color-mix(in srgb, currentColor 22%, transparent);
266
+ --entity-rename-hover-bg: color-mix(in srgb, currentColor 8%, transparent);
267
+ --entity-focus-ring: color-mix(in srgb, currentColor 55%, transparent);
183
268
  }
184
269
 
185
270
  .entity-toggle-list__heading,
@@ -194,21 +279,27 @@
194
279
  .entity-toggle-list__row {
195
280
  display: flex;
196
281
  flex-wrap: wrap;
197
- gap: 4px 8px;
282
+ gap: 6px 8px;
198
283
  align-items: center;
199
284
  }
200
285
 
201
286
  .entity-toggle-list__item {
202
287
  display: inline-flex;
203
288
  align-items: center;
204
- gap: 6px;
205
- padding: 2px 6px 2px 4px;
206
- border-radius: 4px;
289
+ gap: 8px;
290
+ padding: 6px 8px 6px 10px;
291
+ min-height: 32px;
292
+ border-radius: 6px;
207
293
  background: var(--entity-btn-bg);
294
+ border: 1px solid transparent;
295
+ transition:
296
+ background-color 100ms ease,
297
+ border-color 100ms ease;
208
298
  }
209
299
 
210
300
  .entity-toggle-list__item:hover {
211
301
  background: var(--entity-btn-bg-hover);
302
+ border-color: var(--entity-btn-border-hover);
212
303
  }
213
304
 
214
305
  .entity-toggle-list__item.is-hidden .entity-toggle-list__label {
@@ -225,8 +316,9 @@
225
316
  width: var(--entity-swatch-size);
226
317
  height: var(--entity-swatch-size);
227
318
  border-radius: 50%;
228
- border: 1px solid rgba(0, 0, 0, 0.15);
319
+ border: 1px solid var(--entity-swatch-border);
229
320
  flex: 0 0 auto;
321
+ box-shadow: 0 0 0 1px var(--entity-swatch-inset) inset;
230
322
  }
231
323
 
232
324
  .entity-toggle-list__swatch-wrap {
@@ -247,12 +339,74 @@
247
339
  .entity-toggle-list__label {
248
340
  background: none;
249
341
  border: none;
250
- padding: 0;
342
+ padding: 2px 0;
251
343
  margin: 0;
252
344
  font: inherit;
345
+ font-weight: 500;
253
346
  color: var(--entity-btn-fg);
254
347
  cursor: pointer;
255
348
  text-align: left;
349
+ line-height: 1.25;
350
+ }
351
+
352
+ .entity-toggle-list__label:focus-visible {
353
+ outline: 2px solid var(--entity-focus-ring);
354
+ outline-offset: 2px;
355
+ border-radius: 2px;
356
+ }
357
+
358
+ .entity-toggle-list__edit-input {
359
+ background: var(--entity-edit-bg);
360
+ border: 1px solid var(--entity-edit-border);
361
+ border-radius: 4px;
362
+ padding: 3px 6px;
363
+ margin: 0;
364
+ font: inherit;
365
+ font-weight: 500;
366
+ color: var(--entity-btn-fg);
367
+ min-width: 90px;
368
+ max-width: 200px;
369
+ width: auto;
370
+ }
371
+
372
+ .entity-toggle-list__edit-input:focus {
373
+ outline: 2px solid var(--entity-focus-ring);
374
+ outline-offset: 1px;
375
+ }
376
+
377
+ .entity-toggle-list__rename {
378
+ display: inline-flex;
379
+ align-items: center;
380
+ justify-content: center;
381
+ width: 24px;
382
+ height: 24px;
383
+ background: none;
384
+ border: none;
385
+ padding: 0;
386
+ margin: 0;
387
+ border-radius: 4px;
388
+ color: var(--entity-btn-fg-hidden);
389
+ cursor: pointer;
390
+ opacity: 0;
391
+ transition:
392
+ opacity 100ms ease,
393
+ color 100ms ease,
394
+ background-color 100ms ease;
395
+ }
396
+
397
+ .entity-toggle-list__item:hover .entity-toggle-list__rename,
398
+ .entity-toggle-list__rename:focus-visible {
399
+ opacity: 1;
400
+ }
401
+
402
+ .entity-toggle-list__rename:hover {
403
+ color: var(--entity-btn-fg);
404
+ background: var(--entity-rename-hover-bg);
405
+ }
406
+
407
+ .entity-toggle-list__rename:focus-visible {
408
+ outline: 2px solid var(--entity-focus-ring);
409
+ outline-offset: 1px;
256
410
  }
257
411
 
258
412
  .entity-toggle-list__expand {
@@ -22,6 +22,13 @@ interface Props {
22
22
  onToggle?: (id: string, visible: boolean) => void;
23
23
  /** When provided, a native color input is shown and clicking the swatch opens it. */
24
24
  onColorChange?: (id: string, color: string) => void;
25
+ /**
26
+ * When provided, each item gets a pencil button (shown on hover) that
27
+ * enters inline-edit mode. Enter/blur commits via this callback;
28
+ * Escape cancels. Called with the entity's current id and the new
29
+ * label — the consumer is responsible for updating its own state.
30
+ */
31
+ onRename?: (id: string, nextLabel: string) => void;
25
32
  /** Max entities to show before collapsing the remainder behind a "+N more" expander. */
26
33
  maxVisible?: number;
27
34
  /** Optional heading above the list. */
@@ -0,0 +1,272 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cubicOut } from 'svelte/easing';
4
+ import type { TransitionConfig } from 'svelte/transition';
5
+
6
+ /**
7
+ * Slide-in drawer panel. Renders adjacent to an `ActivityBar` in a
8
+ * `CanvasFrame`'s `leftRail` slot (or anywhere the caller places it).
9
+ * The panel itself is purely presentational — the caller owns its
10
+ * open/closed state via the bindable `open` prop.
11
+ *
12
+ * Layout: the panel's width is bindable and mirrors pointer-drag
13
+ * resizes on its right edge. Width is clamped to `[minWidth, maxWidth]`.
14
+ * A symmetric slide+fade handles the open/close animation via Svelte's
15
+ * built-in transition system (no external transition dep), so the panel
16
+ * eases both in and out whenever `open` toggles. `prefers-reduced-motion`
17
+ * collapses the animation to an instant show/hide.
18
+ *
19
+ * Close affordances:
20
+ * - Clicking the × in the default header
21
+ * - Pressing Escape while the panel is open
22
+ * - The caller setting `open = false` programmatically
23
+ *
24
+ * Public class-name contract (stable across versions):
25
+ * .side-panel
26
+ * .side-panel--open
27
+ * .side-panel__header
28
+ * .side-panel__title
29
+ * .side-panel__close
30
+ * .side-panel__body
31
+ * .side-panel__resize-handle
32
+ *
33
+ * @example
34
+ * ```svelte
35
+ * <SidePanel bind:open title="Filters" bind:width>
36
+ * <MyFilterForm />
37
+ * </SidePanel>
38
+ * ```
39
+ */
40
+ interface Props {
41
+ /** Whether the panel is visible. Bindable. */
42
+ open: boolean;
43
+ /** Title rendered in the default header. */
44
+ title?: string;
45
+ /** Panel width in px. Bindable; updated live during resize drags. */
46
+ width?: number;
47
+ /** Minimum width in px. Default: 220. */
48
+ minWidth?: number;
49
+ /** Maximum width in px. Default: 480. */
50
+ maxWidth?: number;
51
+ /** When false, the right-edge resize handle is not rendered. Default: true. */
52
+ resizable?: boolean;
53
+ /** Fired when the panel wants to close (close button, Escape). */
54
+ onClose?: () => void;
55
+ /** Optional replacement for the default header (title + close). */
56
+ header?: Snippet;
57
+ /** Panel body content. */
58
+ children?: Snippet;
59
+ class?: string;
60
+ }
61
+
62
+ let {
63
+ open = $bindable(false),
64
+ title,
65
+ width = $bindable(280),
66
+ minWidth = 220,
67
+ maxWidth = 480,
68
+ resizable = true,
69
+ onClose,
70
+ header,
71
+ children,
72
+ class: className = ''
73
+ }: Props = $props();
74
+
75
+ let isDragging = $state(false);
76
+ let dragStartX = 0;
77
+ let dragStartWidth = 0;
78
+
79
+ // Respect prefers-reduced-motion. Detected once per mount; the slide
80
+ // transition is the only motion gated on this, so a single read is enough.
81
+ const prefersReducedMotion =
82
+ typeof window !== 'undefined' &&
83
+ typeof window.matchMedia === 'function' &&
84
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
85
+
86
+ /**
87
+ * Symmetric horizontal slide + fade used for both enter and exit. Built on
88
+ * Svelte's built-in transition contract (no external dep): Svelte calls it
89
+ * for `in:` and `out:` and plays the same curve forwards/backwards, so the
90
+ * panel eases open and eased shut. Under reduced motion the duration drops
91
+ * to 0 for an instant show/hide.
92
+ */
93
+ function slideFade(
94
+ _node: Element,
95
+ { duration = 170, offset = -16 }: { duration?: number; offset?: number } = {}
96
+ ): TransitionConfig {
97
+ return {
98
+ duration: prefersReducedMotion ? 0 : duration,
99
+ easing: cubicOut,
100
+ css: (t: number) => `transform: translateX(${(1 - t) * offset}px); opacity: ${t};`
101
+ };
102
+ }
103
+
104
+ function handleClose() {
105
+ onClose?.();
106
+ }
107
+
108
+ function handleWindowKeydown(event: KeyboardEvent) {
109
+ if (!open) return;
110
+ if (event.key === 'Escape') {
111
+ event.stopPropagation();
112
+ handleClose();
113
+ }
114
+ }
115
+
116
+ function beginResize(event: PointerEvent) {
117
+ if (!resizable) return;
118
+ event.preventDefault();
119
+ isDragging = true;
120
+ dragStartX = event.clientX;
121
+ dragStartWidth = width;
122
+ document.addEventListener('pointermove', handleResizeMove);
123
+ document.addEventListener('pointerup', endResize);
124
+ document.body.style.cursor = 'col-resize';
125
+ document.body.style.userSelect = 'none';
126
+ }
127
+
128
+ function handleResizeMove(event: PointerEvent) {
129
+ if (!isDragging) return;
130
+ const delta = event.clientX - dragStartX;
131
+ const next = Math.min(maxWidth, Math.max(minWidth, dragStartWidth + delta));
132
+ width = next;
133
+ }
134
+
135
+ function endResize() {
136
+ isDragging = false;
137
+ document.removeEventListener('pointermove', handleResizeMove);
138
+ document.removeEventListener('pointerup', endResize);
139
+ document.body.style.cursor = '';
140
+ document.body.style.userSelect = '';
141
+ }
142
+ </script>
143
+
144
+ <svelte:window onkeydown={handleWindowKeydown} />
145
+
146
+ {#if open}
147
+ <aside
148
+ class="side-panel {open ? 'side-panel--open' : ''} {className}"
149
+ style:width="{width}px"
150
+ aria-hidden={!open}
151
+ transition:slideFade
152
+ >
153
+ {#if header}
154
+ {@render header()}
155
+ {:else}
156
+ <div class="side-panel__header">
157
+ <h2 class="side-panel__title">{title ?? ''}</h2>
158
+ <button
159
+ type="button"
160
+ class="side-panel__close"
161
+ onclick={handleClose}
162
+ aria-label="Close panel"
163
+ title="Close"
164
+ >
165
+ <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
166
+ <path
167
+ d="M18.3 5.71 12 12.01l-6.29-6.3-1.42 1.41 6.3 6.3-6.3 6.29 1.42 1.41 6.29-6.29 6.29 6.29 1.41-1.41-6.29-6.29 6.29-6.3z"
168
+ fill="currentColor"
169
+ />
170
+ </svg>
171
+ </button>
172
+ </div>
173
+ {/if}
174
+
175
+ <div class="side-panel__body">
176
+ {#if children}{@render children()}{/if}
177
+ </div>
178
+
179
+ {#if resizable}
180
+ <div
181
+ class="side-panel__resize-handle"
182
+ role="separator"
183
+ aria-orientation="vertical"
184
+ aria-label="Resize panel"
185
+ onpointerdown={beginResize}
186
+ ></div>
187
+ {/if}
188
+ </aside>
189
+ {/if}
190
+
191
+ <style>
192
+ .side-panel {
193
+ position: relative;
194
+ display: flex;
195
+ flex-direction: column;
196
+ flex: 0 0 auto;
197
+ height: 100%;
198
+ background: var(--side-panel-bg, #fafafa);
199
+ border-right: 1px solid var(--side-panel-border, rgba(0, 0, 0, 0.1));
200
+ box-shadow: var(--side-panel-shadow, 2px 0 6px rgba(0, 0, 0, 0.04));
201
+ overflow: hidden;
202
+ min-height: 0;
203
+ }
204
+
205
+ .side-panel__header {
206
+ display: flex;
207
+ align-items: center;
208
+ justify-content: space-between;
209
+ padding: 10px 12px;
210
+ border-bottom: 1px solid var(--side-panel-border, rgba(0, 0, 0, 0.08));
211
+ flex: 0 0 auto;
212
+ }
213
+
214
+ .side-panel__title {
215
+ margin: 0;
216
+ font:
217
+ 13px/1.3 system-ui,
218
+ -apple-system,
219
+ Segoe UI,
220
+ sans-serif;
221
+ font-weight: 600;
222
+ text-transform: uppercase;
223
+ letter-spacing: 0.04em;
224
+ color: var(--side-panel-title-fg, #555);
225
+ }
226
+
227
+ .side-panel__close {
228
+ display: inline-flex;
229
+ align-items: center;
230
+ justify-content: center;
231
+ width: 24px;
232
+ height: 24px;
233
+ padding: 0;
234
+ border: none;
235
+ background: transparent;
236
+ color: var(--side-panel-close-fg, #666);
237
+ border-radius: 4px;
238
+ cursor: pointer;
239
+ }
240
+
241
+ .side-panel__close:hover {
242
+ background: rgba(0, 0, 0, 0.06);
243
+ color: #111;
244
+ }
245
+
246
+ .side-panel__close:focus-visible {
247
+ outline: 2px solid rgba(59, 130, 246, 0.55);
248
+ outline-offset: 1px;
249
+ }
250
+
251
+ .side-panel__body {
252
+ flex: 1 1 auto;
253
+ min-height: 0;
254
+ overflow: auto;
255
+ }
256
+
257
+ .side-panel__resize-handle {
258
+ position: absolute;
259
+ top: 0;
260
+ right: 0;
261
+ bottom: 0;
262
+ width: 6px;
263
+ cursor: col-resize;
264
+ background: transparent;
265
+ transition: background-color 120ms ease;
266
+ }
267
+
268
+ .side-panel__resize-handle:hover,
269
+ .side-panel__resize-handle:active {
270
+ background: rgba(59, 130, 246, 0.2);
271
+ }
272
+ </style>
@@ -0,0 +1,59 @@
1
+ import type { Snippet } from 'svelte';
2
+ /**
3
+ * Slide-in drawer panel. Renders adjacent to an `ActivityBar` in a
4
+ * `CanvasFrame`'s `leftRail` slot (or anywhere the caller places it).
5
+ * The panel itself is purely presentational — the caller owns its
6
+ * open/closed state via the bindable `open` prop.
7
+ *
8
+ * Layout: the panel's width is bindable and mirrors pointer-drag
9
+ * resizes on its right edge. Width is clamped to `[minWidth, maxWidth]`.
10
+ * A symmetric slide+fade handles the open/close animation via Svelte's
11
+ * built-in transition system (no external transition dep), so the panel
12
+ * eases both in and out whenever `open` toggles. `prefers-reduced-motion`
13
+ * collapses the animation to an instant show/hide.
14
+ *
15
+ * Close affordances:
16
+ * - Clicking the × in the default header
17
+ * - Pressing Escape while the panel is open
18
+ * - The caller setting `open = false` programmatically
19
+ *
20
+ * Public class-name contract (stable across versions):
21
+ * .side-panel
22
+ * .side-panel--open
23
+ * .side-panel__header
24
+ * .side-panel__title
25
+ * .side-panel__close
26
+ * .side-panel__body
27
+ * .side-panel__resize-handle
28
+ *
29
+ * @example
30
+ * ```svelte
31
+ * <SidePanel bind:open title="Filters" bind:width>
32
+ * <MyFilterForm />
33
+ * </SidePanel>
34
+ * ```
35
+ */
36
+ interface Props {
37
+ /** Whether the panel is visible. Bindable. */
38
+ open: boolean;
39
+ /** Title rendered in the default header. */
40
+ title?: string;
41
+ /** Panel width in px. Bindable; updated live during resize drags. */
42
+ width?: number;
43
+ /** Minimum width in px. Default: 220. */
44
+ minWidth?: number;
45
+ /** Maximum width in px. Default: 480. */
46
+ maxWidth?: number;
47
+ /** When false, the right-edge resize handle is not rendered. Default: true. */
48
+ resizable?: boolean;
49
+ /** Fired when the panel wants to close (close button, Escape). */
50
+ onClose?: () => void;
51
+ /** Optional replacement for the default header (title + close). */
52
+ header?: Snippet;
53
+ /** Panel body content. */
54
+ children?: Snippet;
55
+ class?: string;
56
+ }
57
+ declare const SidePanel: import("svelte").Component<Props, {}, "width" | "open">;
58
+ type SidePanel = ReturnType<typeof SidePanel>;
59
+ export default SidePanel;