@kubex/zinc 1.1.78 → 1.1.80
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/dist/custom-elements.json +1149 -130
- package/dist/vscode.html-custom-data.json +175 -1
- package/dist/web-types.json +396 -2
- package/dist/zn.d.ts +384 -0
- package/dist/zn.min.js +350 -295
- package/docs/pages/components/inline-edit.md +21 -0
- package/docs/pages/components/rating.md +2 -1
- package/docs/pages/components/slash-item.md +126 -0
- package/docs/pages/components/slash-menu.md +168 -0
- package/docs/pages/components/textarea.md +148 -0
- package/docs/pages/components/translations.md +34 -0
- package/package.json +1 -1
- package/src/components/inline-edit/inline-edit.component.ts +31 -0
- package/src/components/inline-edit/inline-edit.test.ts +83 -1
- package/src/components/rating/rating.component.ts +10 -1
- package/src/components/rating/rating.scss +6 -9
- package/src/components/slash-item/index.ts +12 -0
- package/src/components/slash-item/slash-item.component.ts +76 -0
- package/src/components/slash-item/slash-item.scss +5 -0
- package/src/components/slash-menu/index.ts +14 -0
- package/src/components/slash-menu/slash-menu-controller.ts +361 -0
- package/src/components/slash-menu/slash-menu-items.ts +122 -0
- package/src/components/slash-menu/slash-menu.component.ts +305 -0
- package/src/components/slash-menu/slash-menu.scss +120 -0
- package/src/components/slash-menu/slash-menu.test.ts +154 -0
- package/src/components/textarea/textarea.component.ts +143 -0
- package/src/components/textarea/textarea.test.ts +310 -2
- package/src/components/translations/translations.component.ts +31 -0
- package/src/components/translations/translations.test.ts +89 -1
- package/src/events/events.ts +2 -0
- package/src/events/zn-slash-insert.ts +9 -0
- package/src/events/zn-slash-select.ts +9 -0
- package/src/utilities/caret-position.ts +118 -0
- package/src/zinc.ts +3 -0
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import '../../../dist/zn.min.js';
|
|
2
|
-
import { expect, fixture, html } from '@open-wc/testing';
|
|
2
|
+
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
|
|
3
3
|
import type ZnInlineEdit from '../inline-edit/inline-edit.component';
|
|
4
|
+
import type ZnSlashMenu from '../slash-menu/slash-menu.component';
|
|
5
|
+
import type ZnTextarea from '../textarea/textarea.component';
|
|
4
6
|
import type ZnTranslations from './translations.component';
|
|
5
7
|
|
|
8
|
+
// The auto-resizing textarea's ResizeObserver can emit a benign "loop completed with undelivered
|
|
9
|
+
// notifications" warning while the slash menu is positioned. It's not a real error — ignore it so the
|
|
10
|
+
// test runner doesn't treat it as an uncaught exception (capture phase runs before the runner's).
|
|
11
|
+
window.addEventListener('error', (e: ErrorEvent) => {
|
|
12
|
+
if (typeof e.message === 'string' && e.message.includes('ResizeObserver loop')) {
|
|
13
|
+
e.stopImmediatePropagation();
|
|
14
|
+
e.preventDefault();
|
|
15
|
+
}
|
|
16
|
+
}, true);
|
|
17
|
+
|
|
6
18
|
describe('<zn-translations>', () => {
|
|
7
19
|
it('should render a component', async () => {
|
|
8
20
|
const el = await fixture(html` <zn-translations></zn-translations> `);
|
|
@@ -37,4 +49,80 @@ describe('<zn-translations>', () => {
|
|
|
37
49
|
await expect(el.values['en']).to.equal('Hello World');
|
|
38
50
|
await expect(JSON.parse(el.value)).to.deep.equal({'en': 'Hello World'});
|
|
39
51
|
});
|
|
52
|
+
|
|
53
|
+
describe('slash menu', () => {
|
|
54
|
+
/** Puts the active language's field into edit mode, the way clicking it does. */
|
|
55
|
+
async function textareaOf(el: ZnTranslations) {
|
|
56
|
+
await el.updateComplete;
|
|
57
|
+
const inlineEdit = el.shadowRoot!.querySelector<ZnInlineEdit>('zn-inline-edit')!;
|
|
58
|
+
await inlineEdit.updateComplete;
|
|
59
|
+
|
|
60
|
+
inlineEdit.shadowRoot!.querySelector<HTMLElement>('.ai__left')!
|
|
61
|
+
.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true}));
|
|
62
|
+
await inlineEdit.updateComplete;
|
|
63
|
+
|
|
64
|
+
const textarea = inlineEdit.shadowRoot!.querySelector<ZnTextarea>('zn-textarea')!;
|
|
65
|
+
await textarea.updateComplete;
|
|
66
|
+
|
|
67
|
+
return textarea;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function openSlashMenu(textarea: ZnTextarea) {
|
|
71
|
+
textarea.focus();
|
|
72
|
+
textarea.input.value = '/';
|
|
73
|
+
textarea.input.setSelectionRange(1, 1);
|
|
74
|
+
textarea.input.dispatchEvent(new InputEvent('input', {bubbles: true, composed: true}));
|
|
75
|
+
|
|
76
|
+
const menuOf = () => textarea.shadowRoot!.querySelector<ZnSlashMenu>('zn-slash-menu');
|
|
77
|
+
await waitUntil(() => menuOf()?.open, 'the slash menu never opened');
|
|
78
|
+
|
|
79
|
+
return menuOf()!;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
it('offers its slash items on the textarea', async () => {
|
|
83
|
+
const el = await fixture<ZnTranslations>(html`
|
|
84
|
+
<zn-translations input-type="textarea"
|
|
85
|
+
slash-items="Brand name={{BRAND_NAME}}, Support email={{SUPPORT_EMAIL}}"></zn-translations>`);
|
|
86
|
+
const menu = await openSlashMenu(await textareaOf(el));
|
|
87
|
+
|
|
88
|
+
expect(menu.items.map(item => item.label)).to.deep.equal(['Brand name', 'Support email']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('offers the same items after switching language', async () => {
|
|
92
|
+
const el = await fixture<ZnTranslations>(html`
|
|
93
|
+
<zn-translations input-type="textarea"
|
|
94
|
+
slash-items="Brand name={{BRAND_NAME}}"></zn-translations>`);
|
|
95
|
+
el.languages = {'en': 'EN', 'fr': 'FR'};
|
|
96
|
+
el.values = {'en': '', 'fr': ''};
|
|
97
|
+
el.setActiveLanguage('fr');
|
|
98
|
+
|
|
99
|
+
const menu = await openSlashMenu(await textareaOf(el));
|
|
100
|
+
|
|
101
|
+
expect(el.getActiveLanguage()).to.equal('fr');
|
|
102
|
+
expect(menu.items.map(item => item.label)).to.deep.equal(['Brand name']);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('inserts the token into the active language only', async () => {
|
|
106
|
+
const el = await fixture<ZnTranslations>(html`
|
|
107
|
+
<zn-translations input-type="textarea"
|
|
108
|
+
slash-items="Brand name={{BRAND_NAME}}"></zn-translations>`);
|
|
109
|
+
el.languages = {'en': 'EN', 'fr': 'FR'};
|
|
110
|
+
el.values = {'en': 'Hello', 'fr': ''};
|
|
111
|
+
el.setActiveLanguage('fr');
|
|
112
|
+
|
|
113
|
+
const textarea = await textareaOf(el);
|
|
114
|
+
await openSlashMenu(textarea);
|
|
115
|
+
|
|
116
|
+
textarea.input.dispatchEvent(new KeyboardEvent('keydown', {
|
|
117
|
+
key: 'Enter',
|
|
118
|
+
bubbles: true,
|
|
119
|
+
composed: true,
|
|
120
|
+
cancelable: true
|
|
121
|
+
}));
|
|
122
|
+
await el.updateComplete;
|
|
123
|
+
|
|
124
|
+
expect(el.values.fr).to.equal('{{BRAND_NAME}}');
|
|
125
|
+
expect(el.values.en, 'the other languages should be untouched').to.equal('Hello');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
40
128
|
});
|
package/src/events/events.ts
CHANGED
|
@@ -19,3 +19,5 @@ export type {ZnPageChangeEvent} from './zn-page-change';
|
|
|
19
19
|
export type {ZnPageSelectionChangeEvent} from './zn-page-selection-change';
|
|
20
20
|
export type {ZnThemeChangeEvent} from './zn-theme-change';
|
|
21
21
|
export type {ZnThemeSubmitEvent} from './zn-theme-submit';
|
|
22
|
+
export type {ZnSlashSelectEvent} from './zn-slash-select';
|
|
23
|
+
export type {ZnSlashInsertEvent} from './zn-slash-insert';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type {SlashMenuItem} from "../components/slash-menu/slash-menu-items";
|
|
2
|
+
|
|
3
|
+
export type ZnSlashInsertEvent = CustomEvent<{ item: SlashMenuItem; value: string }>;
|
|
4
|
+
|
|
5
|
+
declare global {
|
|
6
|
+
interface GlobalEventHandlersEventMap {
|
|
7
|
+
'zn-slash-insert': ZnSlashInsertEvent;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type {SlashMenuItem} from "../components/slash-menu/slash-menu-items";
|
|
2
|
+
|
|
3
|
+
export type ZnSlashSelectEvent = CustomEvent<{ item: SlashMenuItem; query: string }>;
|
|
4
|
+
|
|
5
|
+
declare global {
|
|
6
|
+
interface GlobalEventHandlersEventMap {
|
|
7
|
+
'zn-slash-select': ZnSlashSelectEvent;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export interface CaretCoordinates {
|
|
2
|
+
top: number;
|
|
3
|
+
left: number;
|
|
4
|
+
height: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type TextField = HTMLTextAreaElement | HTMLInputElement;
|
|
8
|
+
|
|
9
|
+
// Properties that affect where a character lands, copied onto the mirror so it
|
|
10
|
+
// wraps text identically to the field itself.
|
|
11
|
+
const mirroredProperties = [
|
|
12
|
+
'direction',
|
|
13
|
+
'boxSizing',
|
|
14
|
+
'width',
|
|
15
|
+
'height',
|
|
16
|
+
'overflowX',
|
|
17
|
+
'overflowY',
|
|
18
|
+
'borderTopWidth',
|
|
19
|
+
'borderRightWidth',
|
|
20
|
+
'borderBottomWidth',
|
|
21
|
+
'borderLeftWidth',
|
|
22
|
+
'borderStyle',
|
|
23
|
+
'paddingTop',
|
|
24
|
+
'paddingRight',
|
|
25
|
+
'paddingBottom',
|
|
26
|
+
'paddingLeft',
|
|
27
|
+
'fontStyle',
|
|
28
|
+
'fontVariant',
|
|
29
|
+
'fontWeight',
|
|
30
|
+
'fontStretch',
|
|
31
|
+
'fontSize',
|
|
32
|
+
'fontSizeAdjust',
|
|
33
|
+
'lineHeight',
|
|
34
|
+
'fontFamily',
|
|
35
|
+
'textAlign',
|
|
36
|
+
'textTransform',
|
|
37
|
+
'textIndent',
|
|
38
|
+
'textDecoration',
|
|
39
|
+
'letterSpacing',
|
|
40
|
+
'wordSpacing',
|
|
41
|
+
'tabSize'
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function lineHeightOf(computed: CSSStyleDeclaration): number {
|
|
45
|
+
const lineHeight = parseFloat(computed.lineHeight);
|
|
46
|
+
if (!Number.isNaN(lineHeight)) return lineHeight;
|
|
47
|
+
|
|
48
|
+
const fontSize = parseFloat(computed.fontSize);
|
|
49
|
+
return Number.isNaN(fontSize) ? 16 : fontSize * 1.2;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Measures where the caret sits inside a text field, relative to the field's own top/left corner.
|
|
54
|
+
* There is no browser API for this, so the field is mirrored into an off-screen div and the offset
|
|
55
|
+
* of a marker span at `index` is read back.
|
|
56
|
+
*/
|
|
57
|
+
export function getCaretCoordinates(field: TextField, index: number): CaretCoordinates {
|
|
58
|
+
const doc = field.ownerDocument;
|
|
59
|
+
const computed = getComputedStyle(field);
|
|
60
|
+
const isInput = field.nodeName === 'INPUT';
|
|
61
|
+
const mirror = doc.createElement('div');
|
|
62
|
+
const style = mirror.style as unknown as Record<string, string>;
|
|
63
|
+
const source = computed as unknown as Record<string, string>;
|
|
64
|
+
|
|
65
|
+
style.position = 'absolute';
|
|
66
|
+
style.visibility = 'hidden';
|
|
67
|
+
style.top = '0';
|
|
68
|
+
style.left = '-9999px';
|
|
69
|
+
style.whiteSpace = isInput ? 'pre' : 'pre-wrap';
|
|
70
|
+
style.overflow = 'hidden';
|
|
71
|
+
if (!isInput) style.wordWrap = 'break-word';
|
|
72
|
+
|
|
73
|
+
mirroredProperties.forEach(prop => {
|
|
74
|
+
const value = source[prop];
|
|
75
|
+
if (typeof value === 'string') style[prop] = value;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Inputs are single line, so the mirror has to grow with its content instead
|
|
79
|
+
if (isInput) style.height = 'auto';
|
|
80
|
+
|
|
81
|
+
const before = field.value.slice(0, index);
|
|
82
|
+
mirror.textContent = isInput ? before.replace(/\s/g, '\u00a0') : before;
|
|
83
|
+
|
|
84
|
+
const marker = doc.createElement('span');
|
|
85
|
+
// A non-empty marker is needed for the browser to give it a position
|
|
86
|
+
marker.textContent = field.value.slice(index) || '.';
|
|
87
|
+
mirror.appendChild(marker);
|
|
88
|
+
|
|
89
|
+
doc.body.appendChild(mirror);
|
|
90
|
+
const coordinates: CaretCoordinates = {
|
|
91
|
+
top: marker.offsetTop + (parseFloat(computed.borderTopWidth) || 0),
|
|
92
|
+
left: marker.offsetLeft + (parseFloat(computed.borderLeftWidth) || 0),
|
|
93
|
+
height: lineHeightOf(computed)
|
|
94
|
+
};
|
|
95
|
+
mirror.remove();
|
|
96
|
+
|
|
97
|
+
return coordinates;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Projects measured caret coordinates onto the viewport, clamped to the field's box so a caret
|
|
102
|
+
* scrolled out of view doesn't drag anchored content off with it. Split from the measurement so a
|
|
103
|
+
* cached measurement can be re-projected as the field scrolls or moves.
|
|
104
|
+
*/
|
|
105
|
+
export function caretRectFrom(field: TextField, {top, left, height}: CaretCoordinates): DOMRect {
|
|
106
|
+
const fieldRect = field.getBoundingClientRect();
|
|
107
|
+
|
|
108
|
+
const maxTop = Math.max(fieldRect.top, fieldRect.bottom - height);
|
|
109
|
+
const y = Math.min(Math.max(fieldRect.top + top - field.scrollTop, fieldRect.top), maxTop);
|
|
110
|
+
const x = Math.min(Math.max(fieldRect.left + left - field.scrollLeft, fieldRect.left), fieldRect.right);
|
|
111
|
+
|
|
112
|
+
return new DOMRect(x, y, 1, height);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The caret's position as a viewport rect. */
|
|
116
|
+
export function getCaretRect(field: TextField, index: number): DOMRect {
|
|
117
|
+
return caretRectFrom(field, getCaretCoordinates(field, index));
|
|
118
|
+
}
|
package/src/zinc.ts
CHANGED
|
@@ -112,6 +112,8 @@ export { default as PagePaletteItem } from './components/page-builder/modules/pa
|
|
|
112
112
|
export { default as PageSectionCard } from './components/page-builder/modules/page-section-card';
|
|
113
113
|
export { default as PreviewFrame } from './components/preview-frame';
|
|
114
114
|
export { default as ThemeEditor } from './components/theme-editor';
|
|
115
|
+
export { default as SlashMenu } from './components/slash-menu';
|
|
116
|
+
export { default as SlashItem } from './components/slash-item';
|
|
115
117
|
/* plop:component */
|
|
116
118
|
|
|
117
119
|
// Base Component
|
|
@@ -122,6 +124,7 @@ export * from './utilities/on';
|
|
|
122
124
|
export * from './utilities/query';
|
|
123
125
|
export * from './utilities/lit-to-html';
|
|
124
126
|
export * from './utilities/form';
|
|
127
|
+
export * from './utilities/caret-position';
|
|
125
128
|
|
|
126
129
|
// Events
|
|
127
130
|
export * from './events/events';
|