@kubex/zinc 1.1.98 → 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.
- package/AGENTS.md +5 -0
- package/CLAUDE.md +5 -0
- package/dist/custom-elements.json +2930 -2072
- package/dist/vscode.html-custom-data.json +198 -59
- package/dist/web-types.json +501 -207
- package/dist/zn.d.ts +203 -9
- package/dist/zn.min.js +343 -297
- package/docs/pages/components/background.md +143 -0
- package/docs/pages/components/checkbox-group.md +13 -1
- package/docs/pages/components/checkbox.md +59 -1
- package/docs/pages/components/radio-group.md +12 -0
- package/docs/pages/components/radio.md +188 -0
- package/docs/pages/components/remarkd-editor.md +22 -0
- package/docs/pages/components/scroll-container.md +14 -15
- package/package.json +1 -1
- package/src/components/background/background.component.ts +111 -0
- package/src/components/background/background.scss +236 -0
- package/src/components/background/background.test.ts +120 -0
- package/src/components/background/index.ts +12 -0
- package/src/components/checkbox/checkbox.component.ts +62 -6
- package/src/components/checkbox/checkbox.scss +1 -0
- package/src/components/checkbox/checkbox.test.ts +56 -0
- package/src/components/checkbox-group/checkbox-group.component.ts +15 -0
- package/src/components/checkbox-group/checkbox-group.scss +23 -5
- package/src/components/checkbox-group/checkbox-group.test.ts +15 -0
- package/src/components/confirm/confirm.component.ts +3 -3
- package/src/components/confirm/confirm.test.ts +15 -0
- package/src/components/dialog/dialog.component.ts +3 -2
- package/src/components/editor/editor.component.ts +7 -6
- package/src/components/header/header.scss +1 -1
- package/src/components/navbar/navbar.scss +11 -0
- package/src/components/radio/radio.component.ts +63 -7
- package/src/components/radio/radio.scss +1 -0
- package/src/components/radio/radio.test.ts +56 -0
- package/src/components/radio-group/radio-group.component.ts +16 -3
- package/src/components/radio-group/radio-group.scss +23 -4
- package/src/components/radio-group/radio-group.test.ts +15 -0
- package/src/components/remarkd-editor/remarkd-editor.component.ts +118 -5
- package/src/components/remarkd-editor/remarkd-editor.scss +8 -1
- package/src/components/remarkd-editor/remarkd-editor.test.ts +96 -0
- package/src/components/scroll-container/scroll-container.component.ts +15 -1
- package/src/components/scroll-container/scroll-container.scss +4 -2
- package/src/components/scroll-container/scroll-container.test.ts +13 -0
- package/src/components/slideout/slideout.component.ts +3 -2
- package/src/components/thumbnail-group/thumbnail-group.scss +0 -1
- package/src/components/tile/tile.scss +4 -2
- package/src/components/translation-group/translation-group.test.ts +22 -0
- package/src/components/translations/translations.component.ts +18 -3
- package/src/form-control.scss +3 -1
- package/src/internal/conditional.ts +2 -2
- package/src/internal/form.ts +2 -1
- package/src/internal/selection-card.ts +19 -0
- package/src/selection-card.scss +187 -0
- package/src/utilities/query.test.ts +18 -1
- package/src/utilities/query.ts +8 -0
- package/src/zinc.ts +1 -0
|
@@ -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
|
-
|
|
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;
|
|
@@ -118,6 +118,102 @@ Second"></zn-remarkd-editor>`);
|
|
|
118
118
|
await expectNamedIconButtons(el, el.shadowRoot!.querySelector('.remarkd-editor__image-controls')!);
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
/** A document tall enough to overflow the editor's max height. */
|
|
122
|
+
const longValue = Array.from({length: 60}, (_, i) => `Paragraph ${i}`).join('\n\n');
|
|
123
|
+
|
|
124
|
+
it('should not grow taller than the viewport', async () => {
|
|
125
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
126
|
+
<zn-remarkd-editor value=${longValue}></zn-remarkd-editor>`);
|
|
127
|
+
expect(el.getBoundingClientRect().height).to.be.at.most(window.innerHeight + 1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/** Adds a block below the middle block via its gutter button. */
|
|
131
|
+
async function addBlockMidDocument(el: ZnRemarkdEditor) {
|
|
132
|
+
const blocks = el.shadowRoot!.querySelectorAll('.remarkd-editor__block');
|
|
133
|
+
blocks[Math.floor(blocks.length / 2)].querySelector('.remarkd-editor__actions zn-button')!
|
|
134
|
+
.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
|
|
135
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__input'), 'no block opened');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
it('should centre a newly added block in its own body', async () => {
|
|
139
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
140
|
+
<zn-remarkd-editor value=${longValue}></zn-remarkd-editor>`);
|
|
141
|
+
const body = el.shadowRoot!.querySelector('.remarkd-editor__body')!;
|
|
142
|
+
await addBlockMidDocument(el);
|
|
143
|
+
|
|
144
|
+
const bodyBox = body.getBoundingClientRect();
|
|
145
|
+
const inputBox = el.shadowRoot!.querySelector('.remarkd-editor__input')!.getBoundingClientRect();
|
|
146
|
+
expect(body.scrollTop, 'the body never scrolled').to.be.greaterThan(0);
|
|
147
|
+
expect(Math.abs((inputBox.top - bodyBox.top) - (bodyBox.bottom - inputBox.bottom)),
|
|
148
|
+
'the new block is not centred').to.be.lessThan(60);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should not scroll an enclosing panel when a block is added', async () => {
|
|
152
|
+
// An `overflow: hidden` panel has no scrollbar but is still scrollable in code, so
|
|
153
|
+
// scrollIntoView() or a plain focus() would shift it with no way to scroll back.
|
|
154
|
+
const panel = await fixture<HTMLDivElement>(html`
|
|
155
|
+
<div style="height: 200px; overflow: hidden">
|
|
156
|
+
<zn-remarkd-editor value=${longValue}></zn-remarkd-editor>
|
|
157
|
+
<div style="height: 900px"></div>
|
|
158
|
+
</div>`);
|
|
159
|
+
const el = panel.querySelector<ZnRemarkdEditor>('zn-remarkd-editor')!;
|
|
160
|
+
await el.updateComplete;
|
|
161
|
+
const body = el.shadowRoot!.querySelector('.remarkd-editor__body')!;
|
|
162
|
+
await addBlockMidDocument(el);
|
|
163
|
+
|
|
164
|
+
expect(body.scrollTop, 'the editor body should still scroll').to.be.greaterThan(0);
|
|
165
|
+
expect(panel.scrollTop, 'the enclosing panel was scrolled').to.equal(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should not scroll an enclosing scroller when an image block is added', async () => {
|
|
169
|
+
const panel = await fixture<HTMLDivElement>(html`
|
|
170
|
+
<div style="height: 200px; overflow: auto">
|
|
171
|
+
<zn-remarkd-editor value=${longValue}></zn-remarkd-editor>
|
|
172
|
+
<div style="height: 900px"></div>
|
|
173
|
+
</div>`);
|
|
174
|
+
const el = panel.querySelector<ZnRemarkdEditor>('zn-remarkd-editor')!;
|
|
175
|
+
await el.updateComplete;
|
|
176
|
+
const body = el.shadowRoot!.querySelector('.remarkd-editor__body')!;
|
|
177
|
+
expect(panel.scrollHeight - panel.clientHeight, 'the panel needs room to scroll')
|
|
178
|
+
.to.be.greaterThan(0);
|
|
179
|
+
|
|
180
|
+
// A 1x1 gif: an image block whose height only lands once it loads.
|
|
181
|
+
const src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
182
|
+
el.value = `${longValue}\n\nimage::${src}[pixel]`;
|
|
183
|
+
await el.updateComplete;
|
|
184
|
+
await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__block img'), 'no image block');
|
|
185
|
+
|
|
186
|
+
expect(panel.scrollTop, 'the enclosing scroller was scrolled').to.equal(0);
|
|
187
|
+
expect(body.scrollTop, 'the body should be scrollable').to.be.at.least(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should keep scrolling while a drag is held at the edge', async () => {
|
|
191
|
+
const el = await fixture<ZnRemarkdEditor>(html`
|
|
192
|
+
<zn-remarkd-editor value=${longValue}></zn-remarkd-editor>`);
|
|
193
|
+
const body = el.shadowRoot!.querySelector('.remarkd-editor__body')!;
|
|
194
|
+
const handle = el.shadowRoot!.querySelectorAll('.remarkd-editor__block')[1]
|
|
195
|
+
.querySelector('.remarkd-editor__drag-handle')!;
|
|
196
|
+
const from = handle.getBoundingClientRect();
|
|
197
|
+
const drag = (type: string, clientY: number, target: EventTarget, buttons = 1) =>
|
|
198
|
+
target.dispatchEvent(new PointerEvent(type, {
|
|
199
|
+
bubbles: true, composed: true, cancelable: true,
|
|
200
|
+
clientX: Math.round(from.left + 5), clientY, button: 0, buttons,
|
|
201
|
+
}));
|
|
202
|
+
|
|
203
|
+
drag('pointerdown', Math.round(from.top + 5), handle);
|
|
204
|
+
drag('pointermove', Math.round(from.top + 20), document);
|
|
205
|
+
expect(el.shadowRoot!.querySelector('.remarkd-editor__ghost'), 'the drag never started').to.exist;
|
|
206
|
+
|
|
207
|
+
// Park the pointer in the bottom edge zone — the frame loop scrolls without further moves.
|
|
208
|
+
drag('pointermove', Math.round(body.getBoundingClientRect().bottom - 20), document);
|
|
209
|
+
await waitUntil(() => body.scrollTop > 0, 'a drag held at the edge never scrolled');
|
|
210
|
+
|
|
211
|
+
drag('pointercancel', 0, document, 0);
|
|
212
|
+
const settled = body.scrollTop;
|
|
213
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
214
|
+
expect(body.scrollTop, 'scrolling continued after the drag ended').to.equal(settled);
|
|
215
|
+
});
|
|
216
|
+
|
|
121
217
|
it('should open zn-slash-menu with the block types when "/" starts an empty block', async () => {
|
|
122
218
|
const el = await fixture<ZnRemarkdEditor>(html`
|
|
123
219
|
<zn-remarkd-editor value="Hello"></zn-remarkd-editor>`);
|
|
@@ -21,11 +21,14 @@ import styles from './scroll-container.scss';
|
|
|
21
21
|
*
|
|
22
22
|
* @csspart base - The component's base wrapper.
|
|
23
23
|
*
|
|
24
|
-
* @cssproperty --
|
|
24
|
+
* @cssproperty --zn-scroll-container-height - The height and maximum height of the scroll container.
|
|
25
25
|
*/
|
|
26
26
|
export default class ZnScrollContainer extends ZincElement {
|
|
27
27
|
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
28
28
|
|
|
29
|
+
/** The height and maximum height of the scroll container. */
|
|
30
|
+
@property({type: String}) height = '';
|
|
31
|
+
|
|
29
32
|
@property({attribute: 'start-scrolled', type: Boolean, reflect: true}) startScrolled: boolean = false;
|
|
30
33
|
|
|
31
34
|
@query('.scroll-container')
|
|
@@ -44,6 +47,17 @@ export default class ZnScrollContainer extends ZincElement {
|
|
|
44
47
|
}
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
protected updated(changedProperties: PropertyValues) {
|
|
51
|
+
super.updated(changedProperties);
|
|
52
|
+
if (changedProperties.has('height')) {
|
|
53
|
+
if (this.height) {
|
|
54
|
+
this.style.setProperty('--zn-scroll-container-height-property', this.height);
|
|
55
|
+
} else {
|
|
56
|
+
this.style.removeProperty('--zn-scroll-container-height-property');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
47
61
|
disconnectedCallback() {
|
|
48
62
|
super.disconnectedCallback();
|
|
49
63
|
if (this._footerHeightFrame !== null) {
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
:host {
|
|
4
4
|
@include wc.scrollbars;
|
|
5
|
-
|
|
6
|
-
display:
|
|
5
|
+
|
|
6
|
+
display: block;
|
|
7
|
+
height: var(--zn-scroll-container-height-property, var(--zn-scroll-container-height, 100%));
|
|
8
|
+
max-height: var(--zn-scroll-container-height-property, var(--zn-scroll-container-height, 100%));
|
|
7
9
|
--zn-scroll-footer-height: 0px;
|
|
8
10
|
--zn-scroll-header-height: 0px;
|
|
9
11
|
}
|
|
@@ -7,4 +7,17 @@ describe('<zn-scroll-container>', () => {
|
|
|
7
7
|
|
|
8
8
|
expect(el).to.exist;
|
|
9
9
|
});
|
|
10
|
+
|
|
11
|
+
it('should render as a block-level container', async () => {
|
|
12
|
+
const el = await fixture(html` <zn-scroll-container></zn-scroll-container> `);
|
|
13
|
+
|
|
14
|
+
expect(getComputedStyle(el).display).to.equal('block');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should apply height as the height and maximum height', async () => {
|
|
18
|
+
const el = await fixture(html` <zn-scroll-container height="240px"></zn-scroll-container> `);
|
|
19
|
+
|
|
20
|
+
expect(getComputedStyle(el).height).to.equal('240px');
|
|
21
|
+
expect(getComputedStyle(el).maxHeight).to.equal('240px');
|
|
22
|
+
});
|
|
10
23
|
});
|
|
@@ -4,6 +4,7 @@ import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
|
|
|
4
4
|
import {LocalizeController} from "../../utilities/localize";
|
|
5
5
|
import {property, query} from 'lit/decorators.js';
|
|
6
6
|
import {getAnimation, setDefaultAnimation} from "../../utilities/animation-registry";
|
|
7
|
+
import {idSelector} from "../../utilities/query";
|
|
7
8
|
import {unlockBodyScrolling} from "../../internal/scroll";
|
|
8
9
|
import ZincElement from '../../internal/zinc-element';
|
|
9
10
|
import ZnButton from "../button";
|
|
@@ -83,7 +84,7 @@ export default class ZnSlideout extends ZincElement {
|
|
|
83
84
|
this.shadowRoot?.addEventListener('click', this.closeClickHandler);
|
|
84
85
|
|
|
85
86
|
if (this.trigger) {
|
|
86
|
-
const trigger = this.parentNode?.querySelector(
|
|
87
|
+
const trigger = this.parentNode?.querySelector(idSelector(this.trigger));
|
|
87
88
|
if (trigger) {
|
|
88
89
|
trigger.addEventListener('click', () => this.show());
|
|
89
90
|
}
|
|
@@ -98,7 +99,7 @@ export default class ZnSlideout extends ZincElement {
|
|
|
98
99
|
this.removeEventListener('click', this.closeClickHandler);
|
|
99
100
|
|
|
100
101
|
if (this.trigger) {
|
|
101
|
-
const trigger = this.parentElement?.querySelector(
|
|
102
|
+
const trigger = this.parentElement?.querySelector(idSelector(this.trigger));
|
|
102
103
|
if (trigger) {
|
|
103
104
|
trigger.removeEventListener('click', () => this.show());
|
|
104
105
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
align-items: center;
|
|
11
11
|
justify-content: space-between;
|
|
12
12
|
min-height: 48px;
|
|
13
|
+
padding: var(--zn-base-gap);
|
|
13
14
|
column-gap: var(--zn-spacing-large);
|
|
14
15
|
|
|
15
16
|
font-size: var(--zn-text-table-content-font-size);
|
|
@@ -23,14 +24,15 @@
|
|
|
23
24
|
&--has-properties,
|
|
24
25
|
&--has-actions {
|
|
25
26
|
padding-inline: var(--zn-base-gap);
|
|
27
|
+
min-height: 85px;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
&--has-description#{&}--has-caption {
|
|
29
|
-
padding-block: var(--zn-
|
|
31
|
+
padding-block: var(--zn-base-gap);
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
&--has-image {
|
|
33
|
-
padding: var(--zn-
|
|
35
|
+
padding: var(--zn-base-gap);
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
&--has-href {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import '../../../dist/zn.min.js';
|
|
2
|
+
import { expect, fixture, html } from '@open-wc/testing';
|
|
3
|
+
import type ZnInlineEdit from '../inline-edit/inline-edit.component';
|
|
4
|
+
import type ZnTranslations from '../translations/translations.component';
|
|
5
|
+
|
|
6
|
+
describe('<zn-translation-group>', () => {
|
|
7
|
+
// The group syncs its children from its own first update, which runs before theirs, so a child's
|
|
8
|
+
// pending value attribute must survive the language keys the group adds.
|
|
9
|
+
it("keeps a child's rendered value when it syncs the group language", async () => {
|
|
10
|
+
const group = await fixture(html`
|
|
11
|
+
<zn-translation-group label="Text" .languages=${{en: 'English', fr: 'French'}}>
|
|
12
|
+
<zn-translations name="heading" value='{"en":"Chat to us"}'></zn-translations>
|
|
13
|
+
</zn-translation-group>`);
|
|
14
|
+
|
|
15
|
+
const child = group.querySelector<ZnTranslations>('zn-translations')!;
|
|
16
|
+
await child.updateComplete;
|
|
17
|
+
|
|
18
|
+
expect(child.values).to.deep.equal({en: 'Chat to us'});
|
|
19
|
+
const input = child.shadowRoot!.querySelector<ZnInlineEdit>('zn-inline-edit')!;
|
|
20
|
+
expect(input.value).to.equal('Chat to us');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -150,15 +150,30 @@ export default class ZnTranslations extends ZincElement implements ZincFormContr
|
|
|
150
150
|
|
|
151
151
|
/** Adds a language key to this component's values if not already present. Used by zn-translation-group. */
|
|
152
152
|
public addLanguageKey(languageCode: string) {
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
const values = this.pendingValues();
|
|
154
|
+
if (!Object.prototype.hasOwnProperty.call(values, languageCode)) {
|
|
155
|
+
this.values = {...values, [languageCode]: ''};
|
|
155
156
|
this.updateValue();
|
|
156
157
|
}
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
/** Returns all language codes that have values. */
|
|
160
161
|
public getValueLanguages(): string[] {
|
|
161
|
-
return Object.keys(this.
|
|
162
|
+
return Object.keys(this.pendingValues());
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* `values`, falling back to the `value` attribute it is built from while that is still pending. A parent
|
|
167
|
+
* zn-translation-group syncs its children from its own first update, which runs before theirs, so reading
|
|
168
|
+
* `values` alone would see it empty and overwrite the value the server rendered.
|
|
169
|
+
*/
|
|
170
|
+
private pendingValues(): Record<string, string> {
|
|
171
|
+
if (Object.keys(this.values).length > 0) return this.values;
|
|
172
|
+
try {
|
|
173
|
+
return JSON.parse(this.value || '{}') as Record<string, string>;
|
|
174
|
+
} catch {
|
|
175
|
+
return this.values;
|
|
176
|
+
}
|
|
162
177
|
}
|
|
163
178
|
|
|
164
179
|
disconnectedCallback() {
|
package/src/form-control.scss
CHANGED
|
@@ -123,6 +123,8 @@
|
|
|
123
123
|
margin-top: var(--zn-spacing-3x-small);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
/* Let contained radios/checkboxes fill their grid row so cards in a row match height. */
|
|
127
|
+
.form-control--checkbox-contained-wrapper,
|
|
128
|
+
.form-control--radio-contained-wrapper {
|
|
127
129
|
height: 100%;
|
|
128
130
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {deepQuerySelectorAll} from '../utilities/query';
|
|
1
|
+
import {deepQuerySelectorAll, idSelector} from '../utilities/query';
|
|
2
2
|
import type {ReactiveController, ReactiveControllerHost} from 'lit';
|
|
3
3
|
|
|
4
4
|
export interface ConditionalHost {
|
|
@@ -26,7 +26,7 @@ export class ConditionalController implements ReactiveController {
|
|
|
26
26
|
const ids = this.host.conditional.split(',').map(id => id.trim());
|
|
27
27
|
|
|
28
28
|
ids.forEach(id => {
|
|
29
|
-
const byId = deepQuerySelectorAll(
|
|
29
|
+
const byId = deepQuerySelectorAll(idSelector(id), document.documentElement, '') as (Element & {
|
|
30
30
|
value: string | string[];
|
|
31
31
|
})[];
|
|
32
32
|
const byName = deepQuerySelectorAll(`[name="${id}"]`, document.documentElement, '') as (Element & {
|
package/src/internal/form.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {getFormNavigationController} from "./form-navigation";
|
|
2
|
+
import {idSelector} from "../utilities/query";
|
|
2
3
|
import {Store} from "./storage";
|
|
3
4
|
import type {ReactiveController, ReactiveControllerHost} from "lit";
|
|
4
5
|
import type {ZincFormControl} from "./zinc-element";
|
|
@@ -256,7 +257,7 @@ export class FormControlController implements ReactiveController {
|
|
|
256
257
|
|
|
257
258
|
if (formId) {
|
|
258
259
|
const root = input.getRootNode() as Document | ShadowRoot | HTMLElement;
|
|
259
|
-
const form = root.querySelector(
|
|
260
|
+
const form = root.querySelector(idSelector(formId));
|
|
260
261
|
|
|
261
262
|
if (form) {
|
|
262
263
|
return form as HTMLFormElement;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Where a selection card renders its image, relative to the card's text. */
|
|
2
|
+
export type SelectionCardImagePosition = 'top' | 'right' | 'bottom' | 'left';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where a selection card renders its radio/checkbox indicator. `start` keeps the standard
|
|
6
|
+
* inline position; `none` hides the indicator so the card itself shows the selected state.
|
|
7
|
+
*/
|
|
8
|
+
export type SelectionCardControlPosition =
|
|
9
|
+
| 'start'
|
|
10
|
+
| 'none'
|
|
11
|
+
| 'top-left'
|
|
12
|
+
| 'top-center'
|
|
13
|
+
| 'top-right'
|
|
14
|
+
| 'center-left'
|
|
15
|
+
| 'center'
|
|
16
|
+
| 'center-right'
|
|
17
|
+
| 'bottom-left'
|
|
18
|
+
| 'bottom-center'
|
|
19
|
+
| 'bottom-right';
|