@kubex/zinc 1.1.97 → 1.1.99

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 (85) hide show
  1. package/AGENTS.md +5 -0
  2. package/CLAUDE.md +5 -0
  3. package/dist/chunks/zn.R2EAU6OS.js +1 -0
  4. package/dist/custom-elements.json +14747 -12528
  5. package/dist/vscode.html-custom-data.json +386 -61
  6. package/dist/web-types.json +1232 -481
  7. package/dist/zn.d.ts +529 -11
  8. package/dist/zn.min.js +649 -478
  9. package/docs/pages/components/background.md +143 -0
  10. package/docs/pages/components/button.md +11 -1
  11. package/docs/pages/components/chart.md +165 -3
  12. package/docs/pages/components/checkbox-group.md +13 -1
  13. package/docs/pages/components/checkbox.md +59 -1
  14. package/docs/pages/components/icon-picker.md +8 -0
  15. package/docs/pages/components/radio-group.md +12 -0
  16. package/docs/pages/components/radio.md +188 -0
  17. package/docs/pages/components/remarkd-editor.md +22 -0
  18. package/docs/pages/components/scroll-container.md +14 -15
  19. package/docs/pages/components/thumbnail-group.md +216 -0
  20. package/docs/pages/components/thumbnail.md +349 -0
  21. package/docs/pages/components/well.md +34 -0
  22. package/package.json +1 -1
  23. package/src/components/background/background.component.ts +111 -0
  24. package/src/components/background/background.scss +236 -0
  25. package/src/components/background/background.test.ts +120 -0
  26. package/src/components/background/index.ts +12 -0
  27. package/src/components/button/button.component.ts +8 -0
  28. package/src/components/chart/builders.test.ts +190 -1
  29. package/src/components/chart/builders.ts +246 -4
  30. package/src/components/chart/chart.component.ts +33 -1
  31. package/src/components/chart/chart.test.ts +66 -1
  32. package/src/components/chart/echarts-loader.ts +10 -0
  33. package/src/components/checkbox/checkbox.component.ts +62 -6
  34. package/src/components/checkbox/checkbox.scss +1 -0
  35. package/src/components/checkbox/checkbox.test.ts +56 -0
  36. package/src/components/checkbox-group/checkbox-group.component.ts +15 -0
  37. package/src/components/checkbox-group/checkbox-group.scss +23 -5
  38. package/src/components/checkbox-group/checkbox-group.test.ts +15 -0
  39. package/src/components/collapsible/collapsible.component.ts +49 -4
  40. package/src/components/collapsible/collapsible.scss +33 -14
  41. package/src/components/confirm/confirm.component.ts +3 -3
  42. package/src/components/confirm/confirm.test.ts +15 -0
  43. package/src/components/dialog/dialog.component.ts +3 -2
  44. package/src/components/editor/editor.component.ts +7 -6
  45. package/src/components/header/header.scss +2 -2
  46. package/src/components/icon-picker/icon-picker.component.ts +3 -0
  47. package/src/components/icon-picker/lucide-icons.ts +1756 -0
  48. package/src/components/navbar/navbar.scss +11 -0
  49. package/src/components/page/page.scss +1 -1
  50. package/src/components/radio/radio.component.ts +63 -7
  51. package/src/components/radio/radio.scss +1 -0
  52. package/src/components/radio/radio.test.ts +56 -0
  53. package/src/components/radio-group/radio-group.component.ts +16 -3
  54. package/src/components/radio-group/radio-group.scss +23 -4
  55. package/src/components/radio-group/radio-group.test.ts +15 -0
  56. package/src/components/remarkd-editor/remarkd-editor.component.ts +118 -5
  57. package/src/components/remarkd-editor/remarkd-editor.scss +8 -1
  58. package/src/components/remarkd-editor/remarkd-editor.test.ts +135 -0
  59. package/src/components/scroll-container/scroll-container.component.ts +15 -1
  60. package/src/components/scroll-container/scroll-container.scss +4 -2
  61. package/src/components/scroll-container/scroll-container.test.ts +13 -0
  62. package/src/components/slideout/slideout.component.ts +3 -2
  63. package/src/components/split-pane/split-pane.scss +21 -15
  64. package/src/components/thumbnail/index.ts +12 -0
  65. package/src/components/thumbnail/thumbnail.component.ts +460 -0
  66. package/src/components/thumbnail/thumbnail.scss +360 -0
  67. package/src/components/thumbnail/thumbnail.test.ts +379 -0
  68. package/src/components/thumbnail-group/index.ts +12 -0
  69. package/src/components/thumbnail-group/thumbnail-group.component.ts +268 -0
  70. package/src/components/thumbnail-group/thumbnail-group.scss +86 -0
  71. package/src/components/thumbnail-group/thumbnail-group.test.ts +151 -0
  72. package/src/components/tile/tile.scss +4 -2
  73. package/src/components/translation-group/translation-group.test.ts +22 -0
  74. package/src/components/translations/translations.component.ts +18 -3
  75. package/src/components/well/well.component.ts +22 -4
  76. package/src/components/well/well.scss +24 -0
  77. package/src/components/well/well.test.ts +43 -0
  78. package/src/form-control.scss +3 -1
  79. package/src/internal/conditional.ts +2 -2
  80. package/src/internal/form.ts +2 -1
  81. package/src/internal/selection-card.ts +19 -0
  82. package/src/selection-card.scss +187 -0
  83. package/src/utilities/query.test.ts +18 -1
  84. package/src/utilities/query.ts +8 -0
  85. package/src/zinc.ts +3 -0
@@ -303,6 +303,17 @@ ul.navbar.navbar--stacked.navbar--icon-bar {
303
303
  }
304
304
  }
305
305
 
306
+ // Icon-bar items are standalone pills, not tabs — the tab-strip rule above
307
+ // leaves them with a top+right border only, which reads as an unfinished box
308
+ :host([icon-bar]:not([stacked])) {
309
+ ul.navbar {
310
+ > li {
311
+ border-width: 1px;
312
+ border-radius: 6px;
313
+ }
314
+ }
315
+ }
316
+
306
317
  :host {
307
318
  display: block;
308
319
  width: 100%;
@@ -180,7 +180,7 @@
180
180
  white-space: nowrap;
181
181
  text-overflow: ellipsis;
182
182
  display: flex;
183
- gap: var(--zn-spacing-x-small);
183
+ gap: var(--zn-spacing-2x-small);
184
184
  align-items: center;
185
185
  text-wrap: auto;
186
186
  }
@@ -9,6 +9,7 @@ import {property, query, state} from 'lit/decorators.js';
9
9
  import {watch} from '../../internal/watch';
10
10
  import ZincElement from '../../internal/zinc-element';
11
11
  import ZnIcon from "../icon";
12
+ import type {SelectionCardControlPosition, SelectionCardImagePosition} from '../../internal/selection-card';
12
13
  import type {ZincFormControl} from '../../internal/zinc-element';
13
14
 
14
15
  import styles from './radio.scss';
@@ -22,6 +23,7 @@ import styles from './radio.scss';
22
23
  * @dependency zn-icon
23
24
  *
24
25
  * @slot - The radio's label.
26
+ * @slot image - Replaces the built-in image used by contained radios.
25
27
  * @slot description - A description of the radio's label. Serves as help text for a radio item. Alternatively, you can use the `description` attribute.
26
28
  * @slot selected-content - Use to nest rich content (like an input) inside a selected radio item. Use only with the contained style.
27
29
  *
@@ -35,9 +37,22 @@ import styles from './radio.scss';
35
37
  * @csspart control - The square container that wraps the radio's checked state.
36
38
  * @csspart control--checked - Matches the control part when the radio is checked.
37
39
  * @csspart checked-icon - The checked icon, an `<zn-icon>` element.
40
+ * @csspart image-container - The wrapper around the built-in image or image slot.
41
+ * @csspart image - The built-in image.
42
+ * @csspart card-title - The title inside a selection card.
38
43
  * @csspart label - The container that wraps the radio's label.
39
44
  * @csspart description - The container that wraps the radio's description.
40
45
  * @csspart selected-content - The container that wraps optional content that appears when a radio is checked.
46
+ *
47
+ * @cssproperty --zn-selection-card-image-width - Width of the built-in card image.
48
+ * @cssproperty --zn-selection-card-image-height - Height of the built-in card image.
49
+ * @cssproperty --zn-selection-card-min-height - Minimum height of a contained radio with an image.
50
+ * @cssproperty --zn-selection-card-content-min-width - Width the card text keeps before it wraps below the image.
51
+ * @cssproperty --zn-selection-card-title-font-size - Font size of a selection card title.
52
+ * @cssproperty --zn-selection-card-padding - Equal inset around the contents of a selection card.
53
+ * @cssproperty --zn-selection-card-gap - Space between the image, title, and indicator gutter.
54
+ * @cssproperty --zn-selection-card-control-offset - Distance between a positioned control and the card edge.
55
+ * @cssproperty --zn-selection-card-border-radius - Corner radius of a contained container.
41
56
  */
42
57
  export default class ZnRadio extends ZincElement implements ZincFormControl {
43
58
  static styles: CSSResultGroup = unsafeCSS(styles);
@@ -48,7 +63,7 @@ export default class ZnRadio extends ZincElement implements ZincFormControl {
48
63
  defaultValue: (control: ZnRadio) => control.defaultChecked,
49
64
  setValue: (control: ZnRadio, checked: boolean) => (control.checked = checked)
50
65
  });
51
- private readonly hasSlotController = new HasSlotController(this, 'description');
66
+ private readonly hasSlotController = new HasSlotController(this, '[default]', 'description', 'image');
52
67
 
53
68
  @query('input[type="radio"]') input: HTMLInputElement;
54
69
 
@@ -62,6 +77,9 @@ export default class ZnRadio extends ZincElement implements ZincFormControl {
62
77
  /** The current value of the radio, submitted as a name/value pair with form data. */
63
78
  @property() value: string;
64
79
 
80
+ /** Title rendered inside the card. The default slot takes precedence when provided. */
81
+ @property({attribute: 'card-title'}) cardTitle = '';
82
+
65
83
  /** The radio's size. */
66
84
  @property({reflect: true}) size: 'small' | 'medium' | 'large' = 'medium';
67
85
 
@@ -74,6 +92,24 @@ export default class ZnRadio extends ZincElement implements ZincFormControl {
74
92
  /** Draws a container around the radio. */
75
93
  @property({type: Boolean, reflect: true}) contained = false;
76
94
 
95
+ /** Squares off the corners of the container drawn by `contained`, which is rounded by default. */
96
+ @property({type: Boolean, reflect: true}) square = false;
97
+
98
+ /** URL for the image shown in a contained radio. */
99
+ @property() src = '';
100
+
101
+ /** Accessible text for the built-in image. Leave empty when the image is decorative. */
102
+ @property({attribute: 'image-alt'}) imageAlt = '';
103
+
104
+ /** Places the image above, beside, or below the radio's text. Requires `contained`. */
105
+ @property({attribute: 'image-position', reflect: true}) imagePosition: SelectionCardImagePosition = 'left';
106
+
107
+ /**
108
+ * Places the radio indicator within a contained card. Use `none` to hide the indicator so the card itself
109
+ * shows the selected state. Requires `contained`.
110
+ */
111
+ @property({attribute: 'control-position', reflect: true}) controlPosition: SelectionCardControlPosition = 'start';
112
+
77
113
  /** Applies styles relevant to radios in a horizontal layout. */
78
114
  @property({type: Boolean, reflect: true}) horizontal = false;
79
115
 
@@ -198,6 +234,9 @@ export default class ZnRadio extends ZincElement implements ZincFormControl {
198
234
  render() {
199
235
  const hasDescriptionSlot = this.hasSlotController.test('description');
200
236
  const hasDescription = this.description ? true : hasDescriptionSlot;
237
+ const hasImage = this.contained && (Boolean(this.src) || this.hasSlotController.test('image'));
238
+ const hasDefaultSlot = this.hasSlotController.test('[default]');
239
+ const isCard = this.contained && (hasImage || Boolean(this.cardTitle) || this.controlPosition !== 'start');
201
240
  const hasLabelSlot = this.hasSlotController.test('label');
202
241
  const hasLabelTooltip = this.hasSlotController.test('label-tooltip');
203
242
  const hasLabel = this.label || hasLabelSlot;
@@ -248,7 +287,11 @@ export default class ZnRadio extends ZincElement implements ZincFormControl {
248
287
  'radio--medium': this.size === 'medium',
249
288
  'radio--large': this.size === 'large',
250
289
  'radio--has-description': hasDescription,
251
- 'radio--has-selected-content': this.hasSlotController.test('selected-content')
290
+ 'radio--has-selected-content': this.hasSlotController.test('selected-content'),
291
+ 'selection-card': isCard,
292
+ 'selection-card--has-image': hasImage,
293
+ [`selection-card--image-${this.imagePosition}`]: hasImage,
294
+ [`selection-card--control-${this.controlPosition}`]: isCard && this.controlPosition !== 'start'
252
295
  })}>
253
296
 
254
297
  <input
@@ -261,7 +304,7 @@ export default class ZnRadio extends ZincElement implements ZincFormControl {
261
304
  .disabled=${this.disabled}
262
305
  .required=${this.required}
263
306
  aria-checked=${this.checked ? 'true' : 'false'}
264
- aria-describedby=${hasDescription ? '' : 'description'}
307
+ aria-describedby=${hasDescription ? 'description' : ''}
265
308
  @click=${this.handleClick}
266
309
  @input=${this.handleInput}
267
310
  @invalid=${this.handleInvalid}
@@ -270,16 +313,29 @@ export default class ZnRadio extends ZincElement implements ZincFormControl {
270
313
  />
271
314
  <span
272
315
  part="control${this.checked ? ' control--checked' : ''}"
273
- class="radio__control">
316
+ class="radio__control selection-card__control">
274
317
  ${this.checked
275
318
  ? html`
276
319
  <zn-icon part="checked-icon" size="18" class="radio__checked-icon"
277
320
  src="radio_button_checked"></zn-icon>`
278
321
  : ''}
279
- </span>
322
+ </span>
323
+
324
+ ${hasImage
325
+ ? html`
326
+ <span part="image-container" class="selection-card__image-container">
327
+ <slot name="image">
328
+ ${this.src
329
+ ? html`<img part="image" class="selection-card__image" src=${this.src} alt=${this.imageAlt}>`
330
+ : ''}
331
+ </slot>
332
+ </span>`
333
+ : ''}
280
334
 
281
- <div part="label" class="radio__label">
282
- <slot></slot>
335
+ <div part="label" class="radio__label selection-card__content">
336
+ <span part="card-title" class="selection-card__title">
337
+ <slot></slot>${hasDefaultSlot ? '' : this.cardTitle}
338
+ </span>
283
339
  <div
284
340
  aria-hidden=${hasDescription ? 'false' : 'true'}
285
341
  class="radio__description"
@@ -1,5 +1,6 @@
1
1
  @use "../../wc";
2
2
  @use "../../form-control";
3
+ @use "../../selection-card";
3
4
 
4
5
  :host {
5
6
  display: block;
@@ -7,4 +7,60 @@ describe('<zn-radio>', () => {
7
7
 
8
8
  expect(el).to.exist;
9
9
  });
10
+
11
+ it('renders card content and a built-in image', async () => {
12
+ const el = await fixture(html`
13
+ <zn-radio
14
+ contained
15
+ card-title="Basic"
16
+ description="For smaller businesses."
17
+ src="plan.svg"
18
+ image-alt="Basic plan">
19
+ </zn-radio>
20
+ `);
21
+
22
+ const image = el.shadowRoot!.querySelector('[part="image"]')!;
23
+ expect(image.getAttribute('src')).to.equal('plan.svg');
24
+ expect(image.getAttribute('alt')).to.equal('Basic plan');
25
+ expect(el.shadowRoot!.querySelector('[part="label"]')!.textContent).to.include('Basic');
26
+ });
27
+
28
+ it('renders card-title when formatting whitespace is present in the default slot', async () => {
29
+ const el = await fixture(html`
30
+ <zn-radio contained card-title="Complete">
31
+ <zn-icon slot="image" src="potted_plant"></zn-icon>
32
+ </zn-radio>
33
+ `);
34
+
35
+ expect(el.shadowRoot!.querySelector('[part="card-title"]')!.textContent).to.include('Complete');
36
+ });
37
+
38
+ it('positions card images and controls', async () => {
39
+ const el = await fixture(html`
40
+ <zn-radio contained image-position="top" control-position="bottom-right">
41
+ Complete
42
+ <zn-icon slot="image" src="potted_plant"></zn-icon>
43
+ </zn-radio>
44
+ `);
45
+ const base = el.shadowRoot!.querySelector('[part="base"]')!;
46
+
47
+ expect(base).to.have.class('selection-card--image-top');
48
+ expect(base).to.have.class('selection-card--control-bottom-right');
49
+ expect(el.shadowRoot!.querySelector('slot[name="image"]')).to.exist;
50
+ });
51
+
52
+ it('leaves a plain contained radio without card styles', async () => {
53
+ const el = await fixture(html`<zn-radio contained description="Supporting text">Basic</zn-radio>`);
54
+
55
+ expect(el.shadowRoot!.querySelector('[part="base"]')).not.to.have.class('selection-card');
56
+ expect(el.shadowRoot!.querySelector('[part="image-container"]')).to.be.null;
57
+ });
58
+
59
+ it('can hide the visual control without removing the native input', async () => {
60
+ const el = await fixture(html`<zn-radio contained control-position="none">Basic</zn-radio>`);
61
+
62
+ expect(el.shadowRoot!.querySelector('input[type="radio"]')).to.exist;
63
+ expect(el.shadowRoot!.querySelector('[part~="control"]')).to.exist;
64
+ expect(el.shadowRoot!.querySelector('[part="base"]')).to.have.class('selection-card--control-none');
65
+ });
10
66
  });
@@ -30,7 +30,7 @@ import styles from './radio-group.scss';
30
30
  *
31
31
  * @csspart base - The component's base wrapper.
32
32
  *
33
- * @cssproperty --example - An example CSS custom property.
33
+ * @cssproperty --zn-radio-group-column-width - Minimum column width used by the horizontal contained grid, or the card basis when `wrap` is set.
34
34
  */
35
35
  export default class ZnRadioGroup extends ZincElement implements ZincFormControl {
36
36
  static styles: CSSResultGroup = unsafeCSS(styles);
@@ -67,12 +67,21 @@ export default class ZnRadioGroup extends ZincElement implements ZincFormControl
67
67
  /** The radio group's size. This size will be applied to all child radios */
68
68
  @property({reflect: true}) size: 'small' | 'medium' | 'large' = 'medium';
69
69
 
70
- /** The checkbox group's orientation. Changes the group's layout from the default (vertical) to horizontal. */
70
+ /** The radio group's orientation. Changes the group's layout from the default (vertical) to horizontal. */
71
71
  @property({type: Boolean, reflect: true}) horizontal = false;
72
72
 
73
- /** The checkbox group's style. Changes the group's style from the default (plain) style to the 'contained' style. This style will be applied to all child checkboxes. */
73
+ /** The radio group's style. Changes the group's style from the default (plain) style to the 'contained' style. This style will be applied to all child radios. */
74
74
  @property({type: Boolean, reflect: true}) contained = false;
75
75
 
76
+ /** Squares off the corners of every contained radio in the group, which are rounded by default. */
77
+ @property({type: Boolean, reflect: true}) square = false;
78
+
79
+ /**
80
+ * Lays horizontal contained radios out as a wrapping row instead of an equal-width grid, so each one sizes from
81
+ * `--zn-radio-group-column-width` (set it to `auto` to size from content) and wraps onto a new line when needed.
82
+ */
83
+ @property({type: Boolean, reflect: true}) wrap = false;
84
+
76
85
  /**
77
86
  * By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
78
87
  * to place the form control outside a form and associate it with the form that has this `id`. The form must be in
@@ -190,6 +199,10 @@ export default class ZnRadioGroup extends ZincElement implements ZincFormControl
190
199
  radio.size = this.size;
191
200
  radio.horizontal = this.horizontal;
192
201
  radio.contained = this.contained;
202
+
203
+ if (this.square) {
204
+ radio.square = true;
205
+ }
193
206
  })
194
207
  );
195
208
 
@@ -8,18 +8,21 @@
8
8
 
9
9
  :host([horizontal]) .form-control-input {
10
10
  display: flex;
11
+ flex-wrap: wrap;
11
12
  column-gap: var(--zn-spacing-2x-large);
13
+ row-gap: var(--zn-spacing-small);
12
14
  }
13
15
 
14
- :host([contained]) .form-control-input {
16
+ /* The label supplies the gap above the options, so a group without one starts flush. */
17
+ :host([contained]) .form-control--has-label .form-control-input {
15
18
  margin-top: var(--zn-spacing-medium);
16
19
  }
17
20
 
18
21
  :host([horizontal][contained]) .form-control-input {
19
- margin-top: var(--zn-spacing-medium);
20
22
  display: grid;
21
- grid-template-columns: repeat(auto-fit, minmax(152px, 1fr));
22
- gap: var(--zn-spacing-x-small);
23
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--zn-radio-group-column-width, 152px)), 1fr));
24
+ align-items: stretch;
25
+ gap: var(--zn-spacing-small);
23
26
  }
24
27
 
25
28
  ::slotted(zn-radio) {
@@ -36,6 +39,22 @@
36
39
  height: 100%;
37
40
  }
38
41
 
42
+ /* `wrap` swaps the equal-width grid for a flex row: cards start from their basis and wrap onto new lines. */
43
+ :host([horizontal][contained][wrap]) .form-control-input {
44
+ display: flex;
45
+ flex-wrap: wrap;
46
+ align-items: stretch;
47
+ gap: var(--zn-base-gap);
48
+ }
49
+
50
+ :host([wrap]) ::slotted(zn-radio[horizontal][contained]) {
51
+ flex: 1 1 var(--zn-radio-group-column-width, 152px);
52
+ min-width: 0;
53
+
54
+ /* A percentage height would resolve against every line, not the card's own. */
55
+ height: auto;
56
+ }
57
+
39
58
  .form-control {
40
59
  position: relative;
41
60
  border: none;
@@ -7,4 +7,19 @@ describe('<zn-radio-group>', () => {
7
7
 
8
8
  expect(el).to.exist;
9
9
  });
10
+
11
+ it('propagates contained and square to its radios', async () => {
12
+ const el = await fixture<HTMLElement>(html`
13
+ <zn-radio-group contained square>
14
+ <zn-radio value="one">One</zn-radio>
15
+ <zn-radio value="two">Two</zn-radio>
16
+ </zn-radio-group>
17
+ `);
18
+ await new Promise(resolve => requestAnimationFrame(resolve));
19
+
20
+ el.querySelectorAll('zn-radio').forEach(child => {
21
+ expect(child).to.have.attribute('contained');
22
+ expect(child).to.have.attribute('square');
23
+ });
24
+ });
10
25
  });
@@ -110,6 +110,8 @@ const SLASH_ITEMS: SlashMenuItem[] = BLOCK_TYPES.map(item => item.image || item.
110
110
  * @csspart image-controls - The caption / alignment / size panel shown when an image block is clicked.
111
111
  * @csspart include - The chip rendered in place of an `include::` directive.
112
112
  * @csspart include-picker - The inline Include picker opened from the toolbar or "/include".
113
+ *
114
+ * @cssproperty --remarkd-editor-max-height - The tallest the editor grows before its body scrolls. Defaults to `100dvh`.
113
115
  */
114
116
  export default class ZnRemarkdEditor extends ZincElement implements ZincFormControl {
115
117
  static styles: CSSResultGroup = unsafeCSS(styles);
@@ -165,6 +167,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
165
167
  private dragStartX = 0;
166
168
  private dragStartY = 0;
167
169
  private dragGhost: HTMLElement | null = null;
170
+ private dragPointerY = 0;
171
+ private autoScrollFrame: number | null = null;
168
172
 
169
173
  /** The name of the control, submitted as part of form data. */
170
174
  @property() name = '';
@@ -440,14 +444,14 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
440
444
  this.updateBlocks(blocks);
441
445
  }
442
446
 
443
- private startEdit(index: number, draft?: string) {
447
+ private startEdit(index: number, draft?: string, align: 'nearest' | 'center' = 'nearest') {
444
448
  if (this.disabled || this.readonly) return;
445
449
  this.editingDraft = draft ?? this.blocks[index] ?? '';
446
450
  this.editingIndex = index;
447
451
  this.imageEdit = null;
448
452
  this.editShell = this.computeEditShell(this.editingDraft);
449
453
  this.slashController.close();
450
- void this.focusInput();
454
+ void this.focusInput(align);
451
455
  }
452
456
 
453
457
  private updateImageEdit(patch: Partial<ImageBlockData>) {
@@ -507,24 +511,83 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
507
511
  return '';
508
512
  }
509
513
 
510
- private async focusInput() {
514
+ private async focusInput(align: 'nearest' | 'center' = 'nearest') {
511
515
  await this.updateComplete;
512
516
  const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input');
513
517
  if (input) {
514
518
  // Each edit renders its own textarea, so the slash menu is re-pointed at it.
515
519
  this.slashController.attach(input);
516
520
  this.autosize(input);
517
- input.focus();
521
+ // preventScroll because the browser's own focus scroll walks every scrollable
522
+ // ancestor — including the surrounding page panel. reveal() stays in the editor.
523
+ input.focus({preventScroll: true});
518
524
  input.setSelectionRange(input.value.length, input.value.length);
525
+ this.reveal(input, align);
519
526
  }
520
527
  this.suppressBlurCommit = false;
521
528
  }
522
529
 
530
+ /**
531
+ * Scrolls the editor's own body to bring `el` into view. Never `scrollIntoView()` and
532
+ * never `focus()` without preventScroll: both walk every scrollable ancestor, and an
533
+ * `overflow: hidden`/`auto` panel around the editor is scrollable too — that shifts a
534
+ * container the user never asked to move.
535
+ */
536
+ private reveal(el: Element | null | undefined, align: 'nearest' | 'center' = 'nearest') {
537
+ const body = this.shadowRoot?.querySelector('.remarkd-editor__body');
538
+ if (!el || !body) return;
539
+
540
+ const target = el.getBoundingClientRect();
541
+ const view = body.getBoundingClientRect();
542
+
543
+ // scrollTop clamps itself, so an overshoot at either end lands at the limit.
544
+ if (align === 'center') {
545
+ // Taller than the view — a large image, say — can't be centred without hiding its
546
+ // top, so anchor the top and let the rest run off the bottom.
547
+ body.scrollTop += target.height > view.height
548
+ ? target.top - view.top
549
+ : (target.top + target.height / 2) - (view.top + view.height / 2);
550
+ } else if (target.top < view.top) {
551
+ body.scrollTop += target.top - view.top;
552
+ } else if (target.bottom > view.bottom) {
553
+ body.scrollTop += target.bottom - view.bottom;
554
+ }
555
+ }
556
+
557
+ private async revealAfterUpdate(selector: string, align: 'nearest' | 'center' = 'nearest') {
558
+ await this.updateComplete;
559
+ this.reveal(this.shadowRoot?.querySelector(selector), align);
560
+ }
561
+
562
+ /**
563
+ * Centres a block added without opening an editor. An image has no height until it
564
+ * loads, so the first pass scrolls against a layout that is still short — the second
565
+ * pass corrects it once the real dimensions are in.
566
+ */
567
+ private async revealBlock(index: number) {
568
+ await this.updateComplete;
569
+ const blockAt = () => this.shadowRoot?.querySelectorAll('.remarkd-editor__block')[index];
570
+ const block = blockAt();
571
+ if (!block) return;
572
+ this.reveal(block, 'center');
573
+
574
+ const loading = [...block.querySelectorAll('img')].filter(img => !img.complete);
575
+ if (!loading.length) return;
576
+
577
+ await Promise.all(loading.map(img => new Promise<void>(resolve => {
578
+ img.addEventListener('load', () => resolve(), {once: true});
579
+ img.addEventListener('error', () => resolve(), {once: true});
580
+ })));
581
+ await new Promise(requestAnimationFrame);
582
+ this.reveal(blockAt(), 'center');
583
+ }
584
+
523
585
  private addBlockAt(index: number, content: string) {
524
586
  if (this.disabled || this.readonly) return;
525
587
  const blocks = [...this.blocks];
526
588
  blocks.splice(index, 0, content);
527
589
  this.updateBlocks(blocks);
590
+ void this.revealBlock(index);
528
591
  }
529
592
 
530
593
  private deleteBlock(index: number) {
@@ -540,7 +603,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
540
603
  const blocks = [...this.blocks];
541
604
  blocks.splice(index, 0, '');
542
605
  this.blocks = blocks;
543
- this.startEdit(index, prefill);
606
+ this.startEdit(index, prefill, 'center');
544
607
  }
545
608
 
546
609
  /**
@@ -714,10 +777,56 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
714
777
  document.body.style.cursor = 'grabbing';
715
778
  }
716
779
 
780
+ this.dragPointerY = e.clientY;
717
781
  this.moveDragGhost(e.clientX, e.clientY);
718
782
  this.dropIndicator = this.insertionIndexFromY(e.clientY);
783
+ this.autoScrollFrame ??= requestAnimationFrame(this.stepAutoScroll);
719
784
  };
720
785
 
786
+ /**
787
+ * Holding the pointer near a scroll edge keeps the content moving, so a block can
788
+ * be dragged past the visible slice of a long document. Runs per frame rather than
789
+ * per pointermove — a pointer parked at the edge stops emitting moves.
790
+ */
791
+ private stepAutoScroll = () => {
792
+ this.autoScrollFrame = null;
793
+ if (this.dragIndex === null || !this.autoScroll()) return;
794
+ // The content moved under a possibly stationary pointer, so the drop target shifts.
795
+ this.dropIndicator = this.insertionIndexFromY(this.dragPointerY);
796
+ this.autoScrollFrame = requestAnimationFrame(this.stepAutoScroll);
797
+ };
798
+
799
+ /** Scrolls the editor body, falling through to the page once the body is at its limit. */
800
+ private autoScroll(): boolean {
801
+ const body = this.shadowRoot?.querySelector('.remarkd-editor__body');
802
+ if (body) {
803
+ const {top, bottom} = body.getBoundingClientRect();
804
+ const delta = this.edgeScrollDelta(top, bottom);
805
+ const before = body.scrollTop;
806
+ if (delta) {
807
+ body.scrollTop += delta;
808
+ if (body.scrollTop !== before) return true;
809
+ }
810
+ }
811
+
812
+ const pageDelta = this.edgeScrollDelta(0, window.innerHeight);
813
+ if (!pageDelta) return false;
814
+ const before = window.scrollY;
815
+ window.scrollBy(0, pageDelta);
816
+ return window.scrollY !== before;
817
+ }
818
+
819
+ /** Scroll step for this frame: nothing until the pointer is within `edge` of a boundary. */
820
+ private edgeScrollDelta(top: number, bottom: number): number {
821
+ const edge = 56;
822
+ const max = 18;
823
+ const ramp = (depth: number) => Math.ceil((Math.min(depth, edge) / edge) * max);
824
+
825
+ if (this.dragPointerY < top + edge) return -ramp(top + edge - this.dragPointerY);
826
+ if (this.dragPointerY > bottom - edge) return ramp(this.dragPointerY - (bottom - edge));
827
+ return 0;
828
+ }
829
+
721
830
  private handleDragPointerUp = (e: PointerEvent) => {
722
831
  const from = this.dragIndex;
723
832
  let to = from !== null ? (this.dropIndicator ?? this.insertionIndexFromY(e.clientY)) : null;
@@ -732,6 +841,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
732
841
 
733
842
  private cancelDrag = () => {
734
843
  this.pendingDragHandle = null;
844
+ if (this.autoScrollFrame !== null) cancelAnimationFrame(this.autoScrollFrame);
845
+ this.autoScrollFrame = null;
735
846
  document.removeEventListener('pointermove', this.handleDragPointerMove);
736
847
  document.removeEventListener('pointerup', this.handleDragPointerUp);
737
848
  document.removeEventListener('pointercancel', this.cancelDrag);
@@ -761,6 +872,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
761
872
  if (this.disabled || this.readonly) return;
762
873
  this.includePickerIndex = null;
763
874
  this.imagePickerIndex = index;
875
+ void this.revealAfterUpdate('.remarkd-editor__image-picker', 'center');
764
876
  }
765
877
 
766
878
  private pickInclude(index: number) {
@@ -769,6 +881,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
769
881
  this.includeQuery = '';
770
882
  this.includePickerIndex = index;
771
883
  void this.loadIncludeOptions();
884
+ void this.revealAfterUpdate('.remarkd-editor__include-picker', 'center');
772
885
  }
773
886
 
774
887
  private closeIncludePicker = () => {
@@ -4,10 +4,16 @@
4
4
 
5
5
  :host {
6
6
  display: block;
7
+ overflow: hidden;
7
8
  }
8
9
 
9
10
  .remarkd-editor {
10
11
  position: relative;
12
+ display: flex;
13
+ flex-direction: column;
14
+ // A long document scrolls the body instead of growing the page past one screenful,
15
+ // which also keeps the toolbar in reach.
16
+ max-height: var(--remarkd-editor-max-height, 100dvh);
11
17
  border: 1px solid rgb(var(--zn-border-color));
12
18
  border-radius: var(--zn-border-radius);
13
19
  background-color: rgba(var(--zn-panel), var(--zn-panel-opacity));
@@ -15,6 +21,7 @@
15
21
 
16
22
  .remarkd-editor__toolbar {
17
23
  display: flex;
24
+ flex: none;
18
25
  gap: 2px;
19
26
  flex-wrap: wrap;
20
27
  padding: var(--zn-spacing-x-small);
@@ -24,6 +31,7 @@
24
31
  .remarkd-editor__body {
25
32
  padding: var(--zn-spacing-large) var(--zn-spacing-large) var(--zn-spacing-large) 52px;
26
33
  min-height: 10rem;
34
+ overflow-y: auto;
27
35
  }
28
36
 
29
37
  // Raw mode has no gutter handles, so it reclaims the left padding.
@@ -92,7 +100,6 @@
92
100
  }
93
101
 
94
102
 
95
-
96
103
  .remarkd-editor__drag-handle {
97
104
  display: inline-flex;
98
105
  align-items: center;