@classic-homes/theme-svelte 1.20.0 → 1.22.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.
@@ -0,0 +1,98 @@
1
+ /**
2
+ * StudioCanvasItem: picking an item opens the inspector on phones unless it
3
+ * is edited in place; only picking it from elsewhere scrolls it into view;
4
+ * the "+" can sit in the gutter.
5
+ */
6
+ import { describe, it, expect, vi, afterEach } from 'vitest';
7
+ import { mount, unmount, flushSync, createRawSnippet } from 'svelte';
8
+ const revealInspector = vi.fn();
9
+ vi.mock('../lib/components/studio/context.js', () => ({
10
+ getStudioContext: () => ({ revealInspector }),
11
+ setStudioContext: () => { },
12
+ }));
13
+ const { default: StudioCanvasItem } = await import('../lib/components/studio/StudioCanvasItem.svelte');
14
+ let instance = null;
15
+ let host;
16
+ function render(props) {
17
+ host = document.createElement('div');
18
+ document.body.appendChild(host);
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ instance = mount(StudioCanvasItem, {
21
+ target: host,
22
+ props: {
23
+ label: 'Block 1',
24
+ children: createRawSnippet(() => ({ render: () => '<p>Body</p>' })),
25
+ ...props,
26
+ },
27
+ });
28
+ flushSync();
29
+ return host;
30
+ }
31
+ afterEach(() => {
32
+ if (instance)
33
+ unmount(instance);
34
+ instance = null;
35
+ host?.remove();
36
+ revealInspector.mockClear();
37
+ });
38
+ describe('StudioCanvasItem revealOnSelect', () => {
39
+ it('reveals the inspector when picked, by default', () => {
40
+ const el = render({ onSelect: vi.fn() });
41
+ el.querySelector('p').click();
42
+ expect(revealInspector).toHaveBeenCalledTimes(1);
43
+ });
44
+ it('selects without revealing the inspector when edited in place', () => {
45
+ const onSelect = vi.fn();
46
+ const el = render({ onSelect, revealOnSelect: false });
47
+ el.querySelector('p').click();
48
+ expect(onSelect).toHaveBeenCalledTimes(1);
49
+ expect(revealInspector).not.toHaveBeenCalled();
50
+ });
51
+ });
52
+ describe('StudioCanvasItem scrolling', () => {
53
+ const scrollIntoView = vi.fn();
54
+ Element.prototype.scrollIntoView = scrollIntoView;
55
+ afterEach(() => scrollIntoView.mockClear());
56
+ async function settle() {
57
+ flushSync();
58
+ await Promise.resolve();
59
+ await Promise.resolve();
60
+ }
61
+ it('scrolls into view when selected from elsewhere', async () => {
62
+ render({ selected: false });
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
+ unmount(instance);
65
+ render({ selected: true });
66
+ await settle();
67
+ expect(scrollIntoView).toHaveBeenCalled();
68
+ });
69
+ it('does not move when picked on the canvas', async () => {
70
+ let selected = $state(false);
71
+ const el = render({
72
+ get selected() {
73
+ return selected;
74
+ },
75
+ onSelect: () => (selected = true),
76
+ });
77
+ const body = el.querySelector('p');
78
+ body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
79
+ body.click();
80
+ await settle();
81
+ expect(selected).toBe(true);
82
+ expect(scrollIntoView).not.toHaveBeenCalled();
83
+ });
84
+ });
85
+ describe('StudioCanvasItem insertPlacement', () => {
86
+ it('centres the "+" by default and puts it in the gutter on request', () => {
87
+ let el = render({ onInsert: vi.fn() });
88
+ expect(el.querySelector('button[aria-label="Add below Block 1"]').className).toContain('left-1/2');
89
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
90
+ unmount(instance);
91
+ el = render({ onInsert: vi.fn(), insertPlacement: 'gutter', selected: true });
92
+ const plus = el.querySelector('button[aria-label="Add below Block 1"]');
93
+ expect(plus.className).toContain('right-full');
94
+ expect(plus.className).not.toContain('left-1/2');
95
+ // Not pinned visible over a selected item's text
96
+ expect(plus.className).not.toMatch(/(^| )opacity-100( |$)/);
97
+ });
98
+ });
@@ -12,6 +12,11 @@
12
12
  * gets a ring and its `toolbar` (duplicate, delete...). With `onInsert`, a
13
13
  * "+" between this item and the next adds a new one right there. On phones
14
14
  * picking an item opens the inspector sheet.
15
+ *
16
+ * Picking an item from elsewhere (an outline, an issue list) scrolls it into
17
+ * view; picking it on the canvas does not, so it never moves under the
18
+ * pointer. `insertPlacement="gutter"` puts the "+" in the left margin, for
19
+ * items edited in place, where a centred "+" would sit on the text.
15
20
  */
16
21
  interface Props {
17
22
  children: Snippet;
@@ -27,6 +32,13 @@
27
32
  insertLabel?: string;
28
33
  /** Marks the item as having problems */
29
34
  invalid?: boolean;
35
+ /**
36
+ * Open the inspector sheet on phones when the item is picked (default).
37
+ * Turn off for items edited in place, where the sheet would cover them.
38
+ */
39
+ revealOnSelect?: boolean;
40
+ /** Where the "+" sits: centred on the item's bottom edge, or in the left gutter beside it */
41
+ insertPlacement?: 'center' | 'gutter';
30
42
  class?: string;
31
43
  }
32
44
 
@@ -39,11 +51,15 @@
39
51
  onInsert,
40
52
  insertLabel,
41
53
  invalid = false,
54
+ revealOnSelect = true,
55
+ insertPlacement = 'center',
42
56
  class: className,
43
57
  }: Props = $props();
44
58
 
45
59
  const studio = getStudioContext();
46
60
  let element: HTMLDivElement | undefined = $state();
61
+ /** The item was picked on the canvas (pointer or focus), so it is already in view. */
62
+ let pickedHere = false;
47
63
 
48
64
  // Quick actions (toolbar, "+") act on the item without selecting it. A
49
65
  // dialog they open (the insert palette) returns focus to them on close;
@@ -53,11 +69,18 @@
53
69
 
54
70
  function select(reveal: boolean) {
55
71
  if (!selected) onSelect?.();
56
- if (reveal) studio?.revealInspector();
72
+ if (reveal && revealOnSelect) studio?.revealInspector();
57
73
  }
58
74
 
59
75
  $effect(() => {
60
- if (!selected || !element) return;
76
+ if (!selected || !element) {
77
+ pickedHere = false;
78
+ return;
79
+ }
80
+ if (pickedHere) {
81
+ pickedHere = false;
82
+ return;
83
+ }
61
84
  const el = element;
62
85
  void tick().then(() => {
63
86
  // Don't yank the canvas while the user is working inside the item
@@ -82,9 +105,14 @@
82
105
  invalid && 'outline-dashed outline-2 -outline-offset-2 outline-warning/70',
83
106
  className
84
107
  )}
108
+ onpointerdowncapture={(e) => {
109
+ if (!selected && !inQuickAction(e.target)) pickedHere = true;
110
+ }}
85
111
  onclickcapture={(e) => select(!inQuickAction(e.target))}
86
112
  onfocusin={(e) => {
87
- if (!inQuickAction(e.target)) select(false);
113
+ if (inQuickAction(e.target)) return;
114
+ if (!selected) pickedHere = true;
115
+ select(false);
88
116
  }}
89
117
  >
90
118
  {#if !selected}
@@ -115,8 +143,13 @@
115
143
  type="button"
116
144
  aria-label={insertLabel ?? `Add below ${label}`}
117
145
  title={insertLabel ?? 'Add below'}
118
- class="absolute left-1/2 top-0 z-10 flex h-7 w-7 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background text-muted-foreground opacity-0 shadow-sm hover:border-primary hover:text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/item:opacity-100 [@media(hover:none)]:opacity-100"
119
- class:opacity-100={selected}
146
+ class={cn(
147
+ 'absolute top-0 z-10 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background text-muted-foreground opacity-0 shadow-sm hover:border-primary hover:text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/item:opacity-100 [@media(hover:none)]:opacity-100',
148
+ insertPlacement === 'gutter'
149
+ ? 'right-full mr-1.5 group-focus-within/item:opacity-100'
150
+ : 'left-1/2 -translate-x-1/2',
151
+ selected && insertPlacement === 'center' && 'opacity-100'
152
+ )}
120
153
  onclick={(e) => {
121
154
  e.stopPropagation();
122
155
  onInsert?.();
@@ -7,6 +7,11 @@ import { type Snippet } from 'svelte';
7
7
  * gets a ring and its `toolbar` (duplicate, delete...). With `onInsert`, a
8
8
  * "+" between this item and the next adds a new one right there. On phones
9
9
  * picking an item opens the inspector sheet.
10
+ *
11
+ * Picking an item from elsewhere (an outline, an issue list) scrolls it into
12
+ * view; picking it on the canvas does not, so it never moves under the
13
+ * pointer. `insertPlacement="gutter"` puts the "+" in the left margin, for
14
+ * items edited in place, where a centred "+" would sit on the text.
10
15
  */
11
16
  interface Props {
12
17
  children: Snippet;
@@ -22,6 +27,13 @@ interface Props {
22
27
  insertLabel?: string;
23
28
  /** Marks the item as having problems */
24
29
  invalid?: boolean;
30
+ /**
31
+ * Open the inspector sheet on phones when the item is picked (default).
32
+ * Turn off for items edited in place, where the sheet would cover them.
33
+ */
34
+ revealOnSelect?: boolean;
35
+ /** Where the "+" sits: centred on the item's bottom edge, or in the left gutter beside it */
36
+ insertPlacement?: 'center' | 'gutter';
25
37
  class?: string;
26
38
  }
27
39
  declare const StudioCanvasItem: import("svelte").Component<Props, {}, "">;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.20.0",
3
+ "version": "1.22.0",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",