@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.
- package/AGENTS.md +5 -0
- package/CLAUDE.md +5 -0
- package/dist/chunks/zn.R2EAU6OS.js +1 -0
- package/dist/custom-elements.json +14747 -12528
- package/dist/vscode.html-custom-data.json +386 -61
- package/dist/web-types.json +1232 -481
- package/dist/zn.d.ts +529 -11
- package/dist/zn.min.js +649 -478
- package/docs/pages/components/background.md +143 -0
- package/docs/pages/components/button.md +11 -1
- package/docs/pages/components/chart.md +165 -3
- package/docs/pages/components/checkbox-group.md +13 -1
- package/docs/pages/components/checkbox.md +59 -1
- package/docs/pages/components/icon-picker.md +8 -0
- 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/docs/pages/components/thumbnail-group.md +216 -0
- package/docs/pages/components/thumbnail.md +349 -0
- package/docs/pages/components/well.md +34 -0
- 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/button/button.component.ts +8 -0
- package/src/components/chart/builders.test.ts +190 -1
- package/src/components/chart/builders.ts +246 -4
- package/src/components/chart/chart.component.ts +33 -1
- package/src/components/chart/chart.test.ts +66 -1
- package/src/components/chart/echarts-loader.ts +10 -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/collapsible/collapsible.component.ts +49 -4
- package/src/components/collapsible/collapsible.scss +33 -14
- 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 +2 -2
- package/src/components/icon-picker/icon-picker.component.ts +3 -0
- package/src/components/icon-picker/lucide-icons.ts +1756 -0
- package/src/components/navbar/navbar.scss +11 -0
- package/src/components/page/page.scss +1 -1
- 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 +135 -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/split-pane/split-pane.scss +21 -15
- package/src/components/thumbnail/index.ts +12 -0
- package/src/components/thumbnail/thumbnail.component.ts +460 -0
- package/src/components/thumbnail/thumbnail.scss +360 -0
- package/src/components/thumbnail/thumbnail.test.ts +379 -0
- package/src/components/thumbnail-group/index.ts +12 -0
- package/src/components/thumbnail-group/thumbnail-group.component.ts +268 -0
- package/src/components/thumbnail-group/thumbnail-group.scss +86 -0
- package/src/components/thumbnail-group/thumbnail-group.test.ts +151 -0
- 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/components/well/well.component.ts +22 -4
- package/src/components/well/well.scss +24 -0
- package/src/components/well/well.test.ts +43 -0
- 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 +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%;
|
|
@@ -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 ? '' : '
|
|
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
|
-
|
|
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
|
-
<
|
|
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"
|
|
@@ -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 --
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|