@neovici/cosmoz-utils 6.25.1 → 6.27.0
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.
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface Breakpoint {
|
|
2
|
+
/** Reflected as data-breakpoint when this breakpoint is the narrowest match. */
|
|
3
|
+
name?: string;
|
|
4
|
+
/** Matches when container width <= maxWidth. Omit for the base breakpoint (maxWidth ∞, always matches). */
|
|
5
|
+
maxWidth?: number;
|
|
6
|
+
/** Element property writes. */
|
|
7
|
+
props?: Record<string, unknown>;
|
|
8
|
+
/** Attribute writes; boolean true → empty string, false → removed. */
|
|
9
|
+
attrs?: Record<string, string | boolean>;
|
|
10
|
+
}
|
|
11
|
+
interface ManagedKey {
|
|
12
|
+
name: string;
|
|
13
|
+
/** How to write the key on the host. */
|
|
14
|
+
channel: 'prop' | 'attr';
|
|
15
|
+
}
|
|
16
|
+
interface Snapshot {
|
|
17
|
+
breakpoints: Breakpoint[];
|
|
18
|
+
keys: ManagedKey[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Normalize breakpoints into a frozen snapshot.
|
|
22
|
+
* - exactly one breakpoint without maxWidth (the base, required)
|
|
23
|
+
* - keys declared in bounded breakpoints must also be declared in the base
|
|
24
|
+
* breakpoint (every managed key gets a resting value, so exit is well-defined)
|
|
25
|
+
* - keys are the union across breakpoints, prop channel wins on conflicts
|
|
26
|
+
*/
|
|
27
|
+
export declare const normalize: (breakpoints: Breakpoint[]) => Snapshot;
|
|
28
|
+
/**
|
|
29
|
+
* Container-width breakpoint directive for element parts: applies the
|
|
30
|
+
* narrowest matching breakpoint's `props`/`attrs` onto the host and
|
|
31
|
+
* reflects its `name` as `data-breakpoint`, driven by a ResizeObserver on
|
|
32
|
+
* the element.
|
|
33
|
+
*
|
|
34
|
+
* Exactly one base breakpoint (no `maxWidth`) is required and must declare
|
|
35
|
+
* every key used by bounded breakpoints — base values are the resting
|
|
36
|
+
* (exit) state. Snapshots are rebuilt only when `deps` change, so
|
|
37
|
+
* unrelated re-renders don't churn writes.
|
|
38
|
+
*
|
|
39
|
+
* ```js
|
|
40
|
+
* html`<my-list ${breakpoints(() => [
|
|
41
|
+
* { name: 'mini', maxWidth: 480, props: { renderItem: renderMini } },
|
|
42
|
+
* { props: { renderItem: null } },
|
|
43
|
+
* ], [renderMini])}></my-list>`
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export declare const breakpoints: (factory?: () => Breakpoint[], deps?: unknown[]) => unknown;
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { nothing } from 'lit-html';
|
|
2
|
+
import { AsyncDirective, directive, PartType, } from 'lit-html/async-directive.js';
|
|
3
|
+
const depsEqual = (a, b) => (a ?? []).length === (b ?? []).length &&
|
|
4
|
+
(a ?? []).every((v, i) => (b ?? [])[i] === v);
|
|
5
|
+
const matches = (breakpoint, width) => breakpoint.maxWidth == null || width <= breakpoint.maxWidth;
|
|
6
|
+
/**
|
|
7
|
+
* Normalize breakpoints into a frozen snapshot.
|
|
8
|
+
* - exactly one breakpoint without maxWidth (the base, required)
|
|
9
|
+
* - keys declared in bounded breakpoints must also be declared in the base
|
|
10
|
+
* breakpoint (every managed key gets a resting value, so exit is well-defined)
|
|
11
|
+
* - keys are the union across breakpoints, prop channel wins on conflicts
|
|
12
|
+
*/
|
|
13
|
+
export const normalize = (breakpoints) => {
|
|
14
|
+
const base = breakpoints.filter((b) => b.maxWidth == null);
|
|
15
|
+
if (base.length !== 1) {
|
|
16
|
+
throw new Error(`breakpoints: exactly one breakpoint without maxWidth is required (the base), got ${base.length}`);
|
|
17
|
+
}
|
|
18
|
+
const sorted = [...breakpoints].sort((a, b) => (a.maxWidth ?? Infinity) - (b.maxWidth ?? Infinity));
|
|
19
|
+
const collect = (channel) => new Set(sorted.flatMap((b) => Object.keys(b[channel] ?? {})));
|
|
20
|
+
const propKeys = collect('props');
|
|
21
|
+
const attrKeys = collect('attrs');
|
|
22
|
+
const keys = [
|
|
23
|
+
...[...propKeys].map((name) => ({ name, channel: 'prop' })),
|
|
24
|
+
...[...attrKeys]
|
|
25
|
+
.filter((name) => !propKeys.has(name))
|
|
26
|
+
.map((name) => ({ name, channel: 'attr' })),
|
|
27
|
+
];
|
|
28
|
+
const baseKeys = new Set([
|
|
29
|
+
...Object.keys(base[0].props ?? {}),
|
|
30
|
+
...Object.keys(base[0].attrs ?? {}),
|
|
31
|
+
]);
|
|
32
|
+
const orphans = [...propKeys, ...attrKeys].filter((k) => !baseKeys.has(k));
|
|
33
|
+
if (orphans.length > 0) {
|
|
34
|
+
throw new Error(`breakpoints: key(s) [${orphans.join(', ')}] must also be declared in the base breakpoint (no maxWidth)`);
|
|
35
|
+
}
|
|
36
|
+
return { breakpoints: sorted, keys };
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Pure: snapshot × width → resolved values. No host reads.
|
|
40
|
+
* Returns undefined when the width is not resolvable (no layout box, <= 0):
|
|
41
|
+
* the caller must hold last state instead of resolving — 0 would false-match
|
|
42
|
+
* the narrowest breakpoint.
|
|
43
|
+
*/
|
|
44
|
+
const compute = (snapshot, width) => {
|
|
45
|
+
if (width <= 0) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const values = new Map();
|
|
49
|
+
for (const { name } of snapshot.keys) {
|
|
50
|
+
const breakpoint = snapshot.breakpoints.find((b) => matches(b, width) &&
|
|
51
|
+
(b.props?.[name] !== undefined || b.attrs?.[name] !== undefined));
|
|
52
|
+
if (!breakpoint) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
// Strict presence: null is a legitimate value (the exit path), so ??
|
|
56
|
+
// is wrong here — it would reduce a base `null` to "no opinion" and
|
|
57
|
+
// skip the write, stranding a previous breakpoint's override.
|
|
58
|
+
const value = breakpoint.props?.[name] !== undefined
|
|
59
|
+
? breakpoint.props?.[name]
|
|
60
|
+
: breakpoint.attrs?.[name];
|
|
61
|
+
values.set(name, value);
|
|
62
|
+
}
|
|
63
|
+
const named = snapshot.breakpoints.find((b) => b.name && matches(b, width));
|
|
64
|
+
return { breakpointName: named?.name, values };
|
|
65
|
+
};
|
|
66
|
+
const applyValue = (el, key, value) => {
|
|
67
|
+
if (key.channel === 'prop') {
|
|
68
|
+
el[key.name] = value;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (typeof value === 'boolean') {
|
|
72
|
+
if (value) {
|
|
73
|
+
el.setAttribute(key.name, '');
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
el.removeAttribute(key.name);
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
el.setAttribute(key.name, String(value));
|
|
81
|
+
};
|
|
82
|
+
const sameResolved = (a, b) => {
|
|
83
|
+
if (!a || !b || a.breakpointName !== b.breakpointName) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
if (a.values.size !== b.values.size) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
for (const [k, v] of a.values) {
|
|
90
|
+
if (b.values.get(k) !== v) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
};
|
|
96
|
+
class BreakpointsDirective extends AsyncDirective {
|
|
97
|
+
_el;
|
|
98
|
+
_ro;
|
|
99
|
+
_snapshot;
|
|
100
|
+
_deps;
|
|
101
|
+
_resolved;
|
|
102
|
+
constructor(partInfo) {
|
|
103
|
+
super(partInfo);
|
|
104
|
+
if (partInfo.type !== PartType.ELEMENT) {
|
|
105
|
+
throw new Error('breakpoints directive must be used on an element');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
109
|
+
render(_factory, _deps) {
|
|
110
|
+
return nothing;
|
|
111
|
+
}
|
|
112
|
+
update(part, [factory, deps]) {
|
|
113
|
+
if (this._el !== undefined && this._el !== part.element) {
|
|
114
|
+
this._teardown();
|
|
115
|
+
this._el = undefined;
|
|
116
|
+
this._resolved = undefined;
|
|
117
|
+
}
|
|
118
|
+
this._el ??= part.element;
|
|
119
|
+
if (this._snapshot === undefined || !depsEqual(deps, this._deps)) {
|
|
120
|
+
this._deps = deps;
|
|
121
|
+
this._snapshot = normalize(factory());
|
|
122
|
+
// Snapshot change invalidates the memo; re-resolve at current width.
|
|
123
|
+
this._resolved = undefined;
|
|
124
|
+
this._evaluate();
|
|
125
|
+
}
|
|
126
|
+
this._observe();
|
|
127
|
+
if (!this._ro) {
|
|
128
|
+
// update() can run before the element is connected (initial fixture
|
|
129
|
+
// render) or before layout gives it a box; retry on the next
|
|
130
|
+
// microtask so observation + first evaluation happen post-connect.
|
|
131
|
+
queueMicrotask(() => {
|
|
132
|
+
if (!this.isConnected) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this._observe();
|
|
136
|
+
this._evaluate();
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return nothing;
|
|
140
|
+
}
|
|
141
|
+
_observe() {
|
|
142
|
+
if (!this._el?.isConnected || this._ro) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this._ro = new ResizeObserver((entries) => {
|
|
146
|
+
this._evaluate(entries[0]?.contentRect.width ?? 0);
|
|
147
|
+
});
|
|
148
|
+
this._ro.observe(this._el);
|
|
149
|
+
}
|
|
150
|
+
_evaluate(measured) {
|
|
151
|
+
if (!this._el || !this._snapshot) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const width = measured === undefined
|
|
155
|
+
? // No RO measurement yet (snapshot change outside an RO tick):
|
|
156
|
+
// measure synchronously to avoid a pre-breakpoint flash.
|
|
157
|
+
this._el.getBoundingClientRect().width
|
|
158
|
+
: measured;
|
|
159
|
+
const resolved = compute(this._snapshot, width);
|
|
160
|
+
if (resolved) {
|
|
161
|
+
this._apply(resolved);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
_apply(resolved) {
|
|
165
|
+
const el = this._el;
|
|
166
|
+
if (sameResolved(this._resolved, resolved)) {
|
|
167
|
+
return; // RO churn / idempotent re-render — write nothing
|
|
168
|
+
}
|
|
169
|
+
for (const key of this._snapshot.keys) {
|
|
170
|
+
const value = resolved.values.get(key.name);
|
|
171
|
+
if (value === undefined) {
|
|
172
|
+
continue; // no breakpoint has an opinion — never write
|
|
173
|
+
}
|
|
174
|
+
applyValue(el, key, value);
|
|
175
|
+
}
|
|
176
|
+
if (resolved.breakpointName) {
|
|
177
|
+
el.setAttribute('data-breakpoint', resolved.breakpointName);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
el.removeAttribute('data-breakpoint');
|
|
181
|
+
}
|
|
182
|
+
this._resolved = resolved;
|
|
183
|
+
}
|
|
184
|
+
_teardown() {
|
|
185
|
+
this._ro?.disconnect();
|
|
186
|
+
this._ro = undefined;
|
|
187
|
+
}
|
|
188
|
+
disconnected() {
|
|
189
|
+
this._teardown();
|
|
190
|
+
}
|
|
191
|
+
reconnected() {
|
|
192
|
+
this._observe();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Container-width breakpoint directive for element parts: applies the
|
|
197
|
+
* narrowest matching breakpoint's `props`/`attrs` onto the host and
|
|
198
|
+
* reflects its `name` as `data-breakpoint`, driven by a ResizeObserver on
|
|
199
|
+
* the element.
|
|
200
|
+
*
|
|
201
|
+
* Exactly one base breakpoint (no `maxWidth`) is required and must declare
|
|
202
|
+
* every key used by bounded breakpoints — base values are the resting
|
|
203
|
+
* (exit) state. Snapshots are rebuilt only when `deps` change, so
|
|
204
|
+
* unrelated re-renders don't churn writes.
|
|
205
|
+
*
|
|
206
|
+
* ```js
|
|
207
|
+
* html`<my-list ${breakpoints(() => [
|
|
208
|
+
* { name: 'mini', maxWidth: 480, props: { renderItem: renderMini } },
|
|
209
|
+
* { props: { renderItem: null } },
|
|
210
|
+
* ], [renderMini])}></my-list>`
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
export const breakpoints = directive(BreakpointsDirective);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ElementPart } from 'lit-html';
|
|
2
|
+
import { AsyncDirective } from 'lit-html/async-directive.js';
|
|
3
|
+
/**
|
|
4
|
+
* The directive expects this css. Adopt it on your component, set the
|
|
5
|
+
* class `cz-overflow-band` on the band slot, and give it a `max-height`
|
|
6
|
+
* to match one row.
|
|
7
|
+
*/
|
|
8
|
+
export declare const overflowStyle: CSSStyleSheet;
|
|
9
|
+
/**
|
|
10
|
+
* The directive marks the elements that overflow with "[overflowing]"
|
|
11
|
+
* and reports the full list via `onOverflow`. The effect relies mainly
|
|
12
|
+
* on css flex wrapping, so make sure to use the accompanying stylesheet
|
|
13
|
+
* (or match in your own css).
|
|
14
|
+
*/
|
|
15
|
+
export interface OverflowConfig {
|
|
16
|
+
onOverflow: (overflowing: Set<HTMLElement>) => void;
|
|
17
|
+
}
|
|
18
|
+
declare class OverflowDirective extends AsyncDirective {
|
|
19
|
+
#private;
|
|
20
|
+
_slot?: HTMLSlotElement;
|
|
21
|
+
_onOverflow?: (overflowing: Set<HTMLElement>) => void;
|
|
22
|
+
render(config: OverflowConfig): symbol;
|
|
23
|
+
update(part: ElementPart, [config]: [OverflowConfig]): symbol;
|
|
24
|
+
disconnected(): void;
|
|
25
|
+
}
|
|
26
|
+
export declare const overflow: (config: OverflowConfig) => import("lit-html/directive.js").DirectiveResult<typeof OverflowDirective>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { css } from '@pionjs/pion';
|
|
2
|
+
import { noChange } from 'lit-html';
|
|
3
|
+
import { AsyncDirective, directive } from 'lit-html/async-directive.js';
|
|
4
|
+
import { sheet } from '../sheet.js';
|
|
5
|
+
/**
|
|
6
|
+
* The directive expects this css. Adopt it on your component, set the
|
|
7
|
+
* class `cz-overflow-band` on the band slot, and give it a `max-height`
|
|
8
|
+
* to match one row.
|
|
9
|
+
*/
|
|
10
|
+
export const overflowStyle = sheet(css `
|
|
11
|
+
slot.cz-overflow-band {
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-wrap: wrap;
|
|
14
|
+
align-items: flex-start;
|
|
15
|
+
overflow: clip;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
::slotted([overflowing]) {
|
|
19
|
+
visibility: hidden;
|
|
20
|
+
}
|
|
21
|
+
`);
|
|
22
|
+
const members = (slot) => slot.assignedElements({ flatten: true });
|
|
23
|
+
const stable = (a, b) => a != null &&
|
|
24
|
+
b != null &&
|
|
25
|
+
a.size === b.size &&
|
|
26
|
+
[...b].every((el) => a.has(el));
|
|
27
|
+
class OverflowDirective extends AsyncDirective {
|
|
28
|
+
_slot;
|
|
29
|
+
_onOverflow;
|
|
30
|
+
#clipped = new Map();
|
|
31
|
+
#last;
|
|
32
|
+
#observer;
|
|
33
|
+
#classify = (entries) => {
|
|
34
|
+
entries.forEach((entry) => {
|
|
35
|
+
const member = entry.target;
|
|
36
|
+
this.#clipped.set(member, entry.boundingClientRect.height > 0 &&
|
|
37
|
+
entry.intersectionRect.height === 0);
|
|
38
|
+
});
|
|
39
|
+
this.#flush();
|
|
40
|
+
};
|
|
41
|
+
#observe = (member) => {
|
|
42
|
+
this.#observer?.unobserve(member);
|
|
43
|
+
this.#observer?.observe(member);
|
|
44
|
+
};
|
|
45
|
+
#flush = () => {
|
|
46
|
+
if (!this._slot) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const current = members(this._slot);
|
|
50
|
+
current.forEach((member) => {
|
|
51
|
+
const on = this.#clipped.get(member) === true;
|
|
52
|
+
if (on === member.hasAttribute('overflowing')) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (on) {
|
|
56
|
+
member.setAttribute('overflowing', '');
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
member.removeAttribute('overflowing');
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
// a member that left the slot: forget it, stop watching it,
|
|
63
|
+
// remove the mark
|
|
64
|
+
this.#clipped.forEach((_, member) => {
|
|
65
|
+
if (current.includes(member)) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
this.#clipped.delete(member);
|
|
69
|
+
this.#observer?.unobserve(member);
|
|
70
|
+
if (member.hasAttribute('overflowing')) {
|
|
71
|
+
member.removeAttribute('overflowing');
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
const next = new Set(current.filter((member) => this.#clipped.get(member) === true));
|
|
75
|
+
if (stable(this.#last, next)) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.#last = next;
|
|
79
|
+
this._onOverflow?.(next);
|
|
80
|
+
};
|
|
81
|
+
#onSlotChange = () => this.#arm();
|
|
82
|
+
#arm = () => {
|
|
83
|
+
if (!this.#observer || !this._slot) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
for (const member of members(this._slot)) {
|
|
87
|
+
this.#observe(member);
|
|
88
|
+
}
|
|
89
|
+
this.#flush();
|
|
90
|
+
};
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
92
|
+
render(config) {
|
|
93
|
+
return noChange;
|
|
94
|
+
}
|
|
95
|
+
update(part, [config]) {
|
|
96
|
+
this._onOverflow = config.onOverflow;
|
|
97
|
+
const slot = part.element;
|
|
98
|
+
if (this._slot !== slot) {
|
|
99
|
+
this._slot?.removeEventListener('slotchange', this.#onSlotChange);
|
|
100
|
+
this.#observer?.disconnect();
|
|
101
|
+
this.#observer = undefined;
|
|
102
|
+
this._slot = slot;
|
|
103
|
+
slot.addEventListener('slotchange', this.#onSlotChange);
|
|
104
|
+
}
|
|
105
|
+
this.#observer ??= new IntersectionObserver(this.#classify, {
|
|
106
|
+
root: this._slot,
|
|
107
|
+
threshold: [0, 1],
|
|
108
|
+
});
|
|
109
|
+
this.#arm();
|
|
110
|
+
return noChange;
|
|
111
|
+
}
|
|
112
|
+
disconnected() {
|
|
113
|
+
this._slot?.removeEventListener('slotchange', this.#onSlotChange);
|
|
114
|
+
this._slot = undefined;
|
|
115
|
+
this.#observer?.disconnect();
|
|
116
|
+
this.#observer = undefined;
|
|
117
|
+
this.#clipped.clear();
|
|
118
|
+
this.#last = undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export const overflow = directive(OverflowDirective);
|