@kubex/zinc 1.1.98 → 1.1.100
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 +876 -18
- package/dist/vscode.html-custom-data.json +148 -9
- package/dist/web-types.json +306 -12
- package/dist/zn.d.ts +203 -9
- package/dist/zn.min.css +1 -1
- 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/scss/_root.scss +16 -0
- package/scss/mixins/_typography-mixins.scss +1 -0
- package/scss/shared/_button.scss +4 -2
- package/scss/themes/_typography.scss +2 -0
- 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.scss +14 -4
- 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 +3 -1
- package/src/components/menu-item/menu-item.scss +8 -5
- package/src/components/navbar/navbar.scss +11 -0
- package/src/components/page/page.scss +16 -2
- 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 +2 -2
- package/src/components/toggle/toggle.scss +8 -1
- package/src/components/tooltip/tooltip.scss +4 -1
- 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
|
@@ -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
|
}
|
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
&--has-description#{&}--has-caption {
|
|
29
|
-
padding-block: var(--zn-
|
|
29
|
+
padding-block: var(--zn-base-gap);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
&--has-image {
|
|
33
|
-
padding: var(--zn-
|
|
33
|
+
padding: var(--zn-base-gap);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
&--has-href {
|
|
@@ -31,7 +31,11 @@
|
|
|
31
31
|
border-radius: 12px;
|
|
32
32
|
align-items: center;
|
|
33
33
|
background-color: rgb(var(--zn-color-tab));
|
|
34
|
-
|
|
34
|
+
// The unchecked track needs an edge of its own. --zn-color-tab sits close to
|
|
35
|
+
// the surface behind it on dark themes, which left the switch reading as a
|
|
36
|
+
// free-floating knob with no discoverable control boundary. The checked state
|
|
37
|
+
// below repaints the border in the accent colour.
|
|
38
|
+
border: 0.92px solid rgb(var(--zn-border-color));
|
|
35
39
|
margin: var(--zn-toggle-margin);
|
|
36
40
|
transition: color .2s ease-in-out, background-color .4s ease-in-out, border-color .2s ease-in-out;
|
|
37
41
|
}
|
|
@@ -46,6 +50,9 @@
|
|
|
46
50
|
|
|
47
51
|
.switch__control {
|
|
48
52
|
left: calc(100% - 20.32px - 0.92px);
|
|
53
|
+
// The knob carries the on/off state, so it takes the on-primary ink: a
|
|
54
|
+
// white knob on a light accent (neon lime, amber) is invisible.
|
|
55
|
+
background: rgb(var(--zn-color-on-primary, 255, 255, 255));
|
|
49
56
|
}
|
|
50
57
|
}
|
|
51
58
|
|
|
@@ -35,7 +35,10 @@
|
|
|
35
35
|
background-color: var(--arrow-color);
|
|
36
36
|
text-align: start;
|
|
37
37
|
white-space: normal;
|
|
38
|
-
color
|
|
38
|
+
// The bubble is painted in --arrow-color, which resolves to --zn-color-text,
|
|
39
|
+
// so the ink has to be the surface colour rather than a fixed white — on any
|
|
40
|
+
// dark theme the text colour is light and white-on-light is unreadable.
|
|
41
|
+
color: rgb(var(--zn-color-base, 255, 255, 255));
|
|
39
42
|
padding: var(--zn-spacing-x-small) var(--zn-spacing-small);
|
|
40
43
|
pointer-events: none;
|
|
41
44
|
user-select: none;
|
|
@@ -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';
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
@use "sass:map";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Shared presentation for "selection cards" — contained radios and checkboxes that
|
|
5
|
+
* render an image, a title, supporting text, and a repositionable indicator.
|
|
6
|
+
*
|
|
7
|
+
* The host component adds `.selection-card` (plus modifiers) to its `part="base"`
|
|
8
|
+
* element and renders `.selection-card__control`, `.selection-card__image-container`
|
|
9
|
+
* and `.selection-card__content` inside it. Everything is scoped to
|
|
10
|
+
* `:host([contained]) .selection-card` so these rules outrank the plain contained
|
|
11
|
+
* styles, and so a contained radio or checkbox without card content is untouched.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
$control-positions: (
|
|
15
|
+
"top-left": (block: start, inline: start),
|
|
16
|
+
"top-center": (block: start, inline: center),
|
|
17
|
+
"top-right": (block: start, inline: end),
|
|
18
|
+
"center-left": (block: center, inline: start),
|
|
19
|
+
"center": (block: center, inline: center),
|
|
20
|
+
"center-right": (block: center, inline: end),
|
|
21
|
+
"bottom-left": (block: end, inline: start),
|
|
22
|
+
"bottom-center": (block: end, inline: center),
|
|
23
|
+
"bottom-right": (block: end, inline: end),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
:host([contained]) .selection-card {
|
|
27
|
+
--selection-card-padding: var(--zn-selection-card-padding, var(--zn-spacing-large));
|
|
28
|
+
--selection-card-gap: var(--zn-selection-card-gap, var(--zn-spacing-medium));
|
|
29
|
+
--selection-card-image-width: var(--zn-selection-card-image-width, 5rem);
|
|
30
|
+
--selection-card-image-height: var(--zn-selection-card-image-height, 5rem);
|
|
31
|
+
--selection-card-control-offset: var(--zn-selection-card-control-offset, var(--zn-spacing-medium));
|
|
32
|
+
|
|
33
|
+
/* Space an edge-positioned control needs so content never runs underneath it. */
|
|
34
|
+
--selection-card-control-gutter: calc(var(--selection-card-control-offset) + var(--toggle-size) + var(--selection-card-gap));
|
|
35
|
+
|
|
36
|
+
align-items: center;
|
|
37
|
+
align-content: center;
|
|
38
|
+
gap: var(--selection-card-gap);
|
|
39
|
+
padding: var(--selection-card-padding);
|
|
40
|
+
|
|
41
|
+
.selection-card__content {
|
|
42
|
+
flex: 1 1 auto;
|
|
43
|
+
min-width: 0;
|
|
44
|
+
margin-inline-start: 0;
|
|
45
|
+
line-height: var(--zn-line-height-normal);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.selection-card__title {
|
|
49
|
+
display: block;
|
|
50
|
+
font-size: var(--zn-selection-card-title-font-size, var(--zn-font-size-large));
|
|
51
|
+
font-weight: var(--zn-font-weight-semibold);
|
|
52
|
+
line-height: var(--zn-line-height-dense);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* Images */
|
|
56
|
+
&.selection-card--has-image {
|
|
57
|
+
min-height: var(--zn-selection-card-min-height, 9rem);
|
|
58
|
+
|
|
59
|
+
/* Beside an image the text keeps a readable column, and wraps below it when there isn't room. */
|
|
60
|
+
flex-wrap: wrap;
|
|
61
|
+
|
|
62
|
+
.selection-card__content {
|
|
63
|
+
flex-basis: var(--zn-selection-card-content-min-width, 8rem);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.selection-card__image-container {
|
|
68
|
+
flex: 0 0 auto;
|
|
69
|
+
display: flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
justify-content: center;
|
|
72
|
+
|
|
73
|
+
::slotted(*) {
|
|
74
|
+
display: block;
|
|
75
|
+
max-width: 100%;
|
|
76
|
+
max-height: var(--selection-card-image-height);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.selection-card__image {
|
|
81
|
+
display: block;
|
|
82
|
+
width: var(--selection-card-image-width);
|
|
83
|
+
max-width: 100%;
|
|
84
|
+
height: var(--selection-card-image-height);
|
|
85
|
+
object-fit: contain;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* The image renders before the content, so only trailing positions need reordering. */
|
|
89
|
+
&.selection-card--image-right,
|
|
90
|
+
&.selection-card--image-bottom {
|
|
91
|
+
.selection-card__image-container {
|
|
92
|
+
order: 2;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.selection-card__content {
|
|
96
|
+
order: 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
&.selection-card--image-top,
|
|
101
|
+
&.selection-card--image-bottom {
|
|
102
|
+
flex-direction: column;
|
|
103
|
+
flex-wrap: nowrap;
|
|
104
|
+
align-items: stretch;
|
|
105
|
+
justify-content: center;
|
|
106
|
+
|
|
107
|
+
.selection-card__image-container {
|
|
108
|
+
align-self: center;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.selection-card__content {
|
|
112
|
+
/* The basis above is a width; in a column it would set the text block's height. */
|
|
113
|
+
flex: 0 0 auto;
|
|
114
|
+
text-align: center;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* Centred text stays centred: mirror the gutter an edge control needs on both sides. */
|
|
118
|
+
@each $name, $position in $control-positions {
|
|
119
|
+
@if map.get($position, inline) != center {
|
|
120
|
+
&.selection-card--control-#{$name} {
|
|
121
|
+
padding-inline: var(--selection-card-control-gutter);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* Positioned controls */
|
|
128
|
+
@each $name, $position in $control-positions {
|
|
129
|
+
$block: map.get($position, block);
|
|
130
|
+
$inline: map.get($position, inline);
|
|
131
|
+
|
|
132
|
+
&.selection-card--control-#{$name} {
|
|
133
|
+
@if $inline == start {
|
|
134
|
+
padding-inline-start: var(--selection-card-control-gutter);
|
|
135
|
+
} @else if $inline == end {
|
|
136
|
+
padding-inline-end: var(--selection-card-control-gutter);
|
|
137
|
+
} @else if $block == start {
|
|
138
|
+
padding-block-start: var(--selection-card-control-gutter);
|
|
139
|
+
} @else if $block == end {
|
|
140
|
+
padding-block-end: var(--selection-card-control-gutter);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.selection-card__control {
|
|
144
|
+
position: absolute;
|
|
145
|
+
z-index: 1;
|
|
146
|
+
transform: translate(var(--selection-card-control-shift-x, 0), var(--selection-card-control-shift-y, 0));
|
|
147
|
+
|
|
148
|
+
@if $block == center {
|
|
149
|
+
--selection-card-control-shift-y: -50%;
|
|
150
|
+
|
|
151
|
+
inset-block-start: 50%;
|
|
152
|
+
} @else {
|
|
153
|
+
inset-block-#{$block}: var(--selection-card-control-offset);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@if $inline == center {
|
|
157
|
+
--selection-card-control-shift-x: -50%;
|
|
158
|
+
|
|
159
|
+
inset-inline-start: 50%;
|
|
160
|
+
} @else {
|
|
161
|
+
inset-inline-#{$inline}: var(--selection-card-control-offset);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* Hidden control — the card itself becomes the visible indicator. */
|
|
168
|
+
&.selection-card--control-none {
|
|
169
|
+
.selection-card__control {
|
|
170
|
+
display: none;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
&:has(input:focus-visible) {
|
|
174
|
+
outline: var(--zn-focus-ring);
|
|
175
|
+
outline-offset: var(--zn-focus-ring-offset);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* Contained radios and checkboxes are rounded by default; `square` opts out. */
|
|
181
|
+
:host([contained]) [part~="base"] {
|
|
182
|
+
border-radius: var(--zn-selection-card-border-radius, var(--zn-border-radius));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
:host([square]) [part~="base"] {
|
|
186
|
+
border-radius: 0;
|
|
187
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {expect, fixture, html} from '@open-wc/testing';
|
|
2
|
-
import {deepQuery} from './query';
|
|
2
|
+
import {deepQuery, idSelector} from './query';
|
|
3
3
|
|
|
4
4
|
describe('deepQuery', () => {
|
|
5
5
|
it('returns null when no element matches', async () => {
|
|
@@ -61,3 +61,20 @@ describe('deepQuery', () => {
|
|
|
61
61
|
expect(deepQuery('.needle', b)).to.be.null;
|
|
62
62
|
});
|
|
63
63
|
});
|
|
64
|
+
|
|
65
|
+
describe('idSelector', () => {
|
|
66
|
+
it('escapes characters that CSS would otherwise treat as syntax', async () => {
|
|
67
|
+
const id = 'delete-enum-cloud_hosting_&_infrastructure';
|
|
68
|
+
const el = await fixture<HTMLElement>(html`<div id="${id}"></div>`);
|
|
69
|
+
|
|
70
|
+
expect(() => document.querySelector(`#${id}`)).to.throw();
|
|
71
|
+
expect(document.querySelector(idSelector(id))).to.equal(el);
|
|
72
|
+
expect(el.matches(idSelector(id))).to.be.true;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('accepts an id that already carries a leading hash', async () => {
|
|
76
|
+
const el = await fixture<HTMLElement>(html`<div id="plain-id"></div>`);
|
|
77
|
+
|
|
78
|
+
expect(document.querySelector(idSelector('#plain-id'))).to.equal(el);
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/utilities/query.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a valid `#id` selector. Ids can legally contain characters that CSS treats as syntax (`&`, `.`, `:`,
|
|
3
|
+
* a leading digit), so they must be escaped before being used in `querySelector`/`matches`.
|
|
4
|
+
*/
|
|
5
|
+
export function idSelector(id: string): string {
|
|
6
|
+
return `#${CSS.escape(id.startsWith('#') ? id.slice(1) : id)}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
1
9
|
export function deepQuery<T extends Element = Element>(
|
|
2
10
|
selector: string,
|
|
3
11
|
root: Document | ShadowRoot | Element = document,
|
package/src/zinc.ts
CHANGED
|
@@ -117,6 +117,7 @@ export { default as SlashItem } from './components/slash-item';
|
|
|
117
117
|
export { default as ScheduleBuilder } from './components/schedule-builder';
|
|
118
118
|
export { default as Thumbnail } from './components/thumbnail';
|
|
119
119
|
export { default as ThumbnailGroup } from './components/thumbnail-group';
|
|
120
|
+
export { default as Background } from './components/background';
|
|
120
121
|
/* plop:component */
|
|
121
122
|
|
|
122
123
|
// Base Component
|