@avantf/dsh-plugin-base 0.6.0 → 0.6.2
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/lib/bootstrap.d.ts +1 -1
- package/lib/bootstrap.js +1 -1
- package/lib/client/load_more.d.ts +157 -0
- package/lib/client/load_more.d.ts.map +1 -0
- package/lib/client/load_more.js +252 -0
- package/lib/internal.d.ts +2 -0
- package/lib/internal.d.ts.map +1 -1
- package/lib/internal.js +19 -0
- package/package.json +4 -2
- package/src/client/controls.module.css +294 -0
package/lib/bootstrap.d.ts
CHANGED
package/lib/bootstrap.js
CHANGED
|
@@ -6,7 +6,7 @@ import { existsSync, promises as fsp, readFileSync } from 'node:fs';
|
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
8
|
/** The bootstrap version; the resolved framework must satisfy {@link supportedRange}. */
|
|
9
|
-
export const VERSION = '0.6.
|
|
9
|
+
export const VERSION = '0.6.2';
|
|
10
10
|
/**
|
|
11
11
|
* The framework interval this bootstrap can launch.
|
|
12
12
|
*
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Scroll to the end → load the next page", as one DOM controller.
|
|
3
|
+
*
|
|
4
|
+
* **INTERNAL — no stability promise** (内部控制件,不承诺稳定): this module is reachable only through
|
|
5
|
+
* the `./internal` subpath, which the interface generation never covers (`docs/INTERFACE.md` §2/§5).
|
|
6
|
+
* It is not on `.`, it is not frozen with a generation, and a rename or a changed option shape is a
|
|
7
|
+
* base release, not a generation. Consumers (mem's 记忆 tab, mission's paged lists) follow the base.
|
|
8
|
+
*
|
|
9
|
+
* Why a DOM controller and not a React hook: this base has NO `react` peer (`package.json`), and it
|
|
10
|
+
* must stay that way — a Node user of the base must not be pulled into React, and the base ships no
|
|
11
|
+
* browser artifact. So the listener, the rAF throttle and the scrollport lookup live here, and each
|
|
12
|
+
* plugin keeps a thin wrapper of its own (mem: `useLoadMoreOnScroll`). The DOM detail then exists
|
|
13
|
+
* once instead of once per panel.
|
|
14
|
+
*
|
|
15
|
+
* Why the DOM types below are STRUCTURAL instead of `lib.dom`: the base compiles with `lib: es2023`
|
|
16
|
+
* and `types: node`, and adding `DOM` there retypes globals for the whole package (it breaks `fetch`
|
|
17
|
+
* bodies across the existing suite, for one). This module instead names the exact slice it reads —
|
|
18
|
+
* element, document, view — so the base stays node-only, its unit test needs no DOM environment, and
|
|
19
|
+
* a real `HTMLElement` / `Window` satisfies the interfaces as-is.
|
|
20
|
+
*
|
|
21
|
+
* Why a scroll listener and not an `IntersectionObserver` (three reasons measured in mem's panel):
|
|
22
|
+
*
|
|
23
|
+
* 1. **`rootMargin` cannot prefetch here.** A plugin panel usually owns no scrollport — the host's
|
|
24
|
+
* conversation scroll body does — and an ancestor's clip rect is NOT expanded by `rootMargin`
|
|
25
|
+
* (only the intersection root's is), so the promised look-ahead never happens.
|
|
26
|
+
* 2. **An observer only reports threshold CHANGES.** One page that failed to load leaves the
|
|
27
|
+
* sentinel visible, so no new callback arrives and auto-load stays dead until something else
|
|
28
|
+
* moves it. A scroll listener is level-triggered: the next scroll event simply tries again.
|
|
29
|
+
* 3. **The scrollport is found, not assumed.** {@link scrollParentOf} walks up to whatever element
|
|
30
|
+
* really scrolls, so this keeps working if a panel ever gets its own scroller.
|
|
31
|
+
*
|
|
32
|
+
* The gap the listener alone cannot close — and why {@link AutoLoadMore.recheck} exists: a page that
|
|
33
|
+
* does NOT fill the scrollport produces no scroll event, and neither does a virtualized list that
|
|
34
|
+
* reaches its last row (the virtualizer window moves, the scroll position does not). Callers
|
|
35
|
+
* therefore re-check after a render (`recheck`), and after the data version changes
|
|
36
|
+
* (`setVersion`, which re-checks only when the number really moved).
|
|
37
|
+
*
|
|
38
|
+
* **Where the controller stops (the host boundary).** {@link scrollParentOf} has exactly one
|
|
39
|
+
* criterion for a scrollport: the element's OWN computed `overflow-y` is `auto`/`scroll` AND its
|
|
40
|
+
* `scrollHeight > clientHeight`. Two things follow, and both are deliberate rather than bugs to
|
|
41
|
+
* paper over here:
|
|
42
|
+
*
|
|
43
|
+
* - **A host whose PAGE is the scroller silently does not auto-load.** With no element satisfying the
|
|
44
|
+
* criterion, {@link createAutoLoadMore} still attaches the listener to `window` (so nothing throws,
|
|
45
|
+
* and a scrollport that appears later is picked up), but `check()` refuses to fire while it has no
|
|
46
|
+
* scroller: there is no position to read, so the `window` listener alone changes nothing. The
|
|
47
|
+
* caller then owns the gap — call {@link AutoLoadMore.recheck} from its own render/commit path, or
|
|
48
|
+
* pass a real scrollport as `target` (e.g. `document.scrollingElement`, or the element whose
|
|
49
|
+
* `scrollHeight` the page grows).
|
|
50
|
+
* - **The scrollport is resolved once, at creation.** A panel that gains (or replaces) its own
|
|
51
|
+
* scroller later must `dispose()` and recreate the controller; `check` re-probes only while it has
|
|
52
|
+
* no scroller at all.
|
|
53
|
+
*
|
|
54
|
+
* @module client/load_more
|
|
55
|
+
*/
|
|
56
|
+
/** Distance from the scrollport's end, in px, at which the next page is requested. */
|
|
57
|
+
export declare const AUTO_LOAD_MORE_MARGIN_PX = 300;
|
|
58
|
+
/**
|
|
59
|
+
* The slice of an `Element` this kit reads.
|
|
60
|
+
*
|
|
61
|
+
* A real `HTMLElement` satisfies it as-is (`scrollTop` / `scrollHeight` / `clientHeight` /
|
|
62
|
+
* `parentElement` / `ownerDocument` / the listener pair), and a test double is just an object — which
|
|
63
|
+
* is why the controller can be unit-tested without a DOM implementation.
|
|
64
|
+
*/
|
|
65
|
+
export interface ScrollElement {
|
|
66
|
+
readonly scrollTop: number;
|
|
67
|
+
readonly scrollHeight: number;
|
|
68
|
+
readonly clientHeight: number;
|
|
69
|
+
readonly parentElement: ScrollElement | null;
|
|
70
|
+
readonly ownerDocument: ScrollDocument | null;
|
|
71
|
+
addEventListener(type: 'scroll', listener: () => void, options: {
|
|
72
|
+
readonly passive: boolean;
|
|
73
|
+
}): void;
|
|
74
|
+
removeEventListener(type: 'scroll', listener: () => void): void;
|
|
75
|
+
}
|
|
76
|
+
/** The slice of a `Document` the overflow lookup reads: its view, when it has one. */
|
|
77
|
+
export interface ScrollDocument {
|
|
78
|
+
readonly defaultView: ScrollView | null;
|
|
79
|
+
}
|
|
80
|
+
/** The slice of a `Window` the overflow lookup reads. */
|
|
81
|
+
export interface ScrollView {
|
|
82
|
+
getComputedStyle(element: ScrollElement): {
|
|
83
|
+
readonly overflowY: string;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** The caller's half of the controller: what it reads, and what it does when the end is reached. */
|
|
87
|
+
export interface AutoLoadMoreOptions {
|
|
88
|
+
/**
|
|
89
|
+
* Where to start looking for the scrollport: normally the panel root (the scrollport is an
|
|
90
|
+
* ancestor — the host's conversation scroll body), or the scrollport itself when the caller owns one
|
|
91
|
+
* (a virtualized list's `getScrollElement()`). The nearest scroller at or above this node is the one
|
|
92
|
+
* that gets the listener; see {@link scrollParentOf}. `null` (the ref was not filled yet) makes the
|
|
93
|
+
* controller inert until the caller recreates it, which is what an effect that ran before the ref
|
|
94
|
+
* was set needs.
|
|
95
|
+
*/
|
|
96
|
+
readonly target: ScrollElement | null;
|
|
97
|
+
/**
|
|
98
|
+
* Read on EVERY check, never captured: a check can run long after the controller was created, and a
|
|
99
|
+
* captured `true` would keep loading pages after the caller disabled it (loading, no rows left).
|
|
100
|
+
*/
|
|
101
|
+
readonly isEnabled: () => boolean;
|
|
102
|
+
/** Asked for the next page. The caller owns the re-entrancy guard (`loading`), as with a click. */
|
|
103
|
+
readonly onLoadMore: () => void;
|
|
104
|
+
/** Initial data version (a row count). See {@link AutoLoadMore.setVersion}. */
|
|
105
|
+
readonly version?: number;
|
|
106
|
+
/** Overrides {@link AUTO_LOAD_MORE_MARGIN_PX}; a bigger value prefetches a whole row earlier. */
|
|
107
|
+
readonly marginPx?: number;
|
|
108
|
+
}
|
|
109
|
+
/** The handle `createAutoLoadMore` returns. Every method is safe to call after `dispose`. */
|
|
110
|
+
export interface AutoLoadMore {
|
|
111
|
+
/**
|
|
112
|
+
* Check the scroll position RIGHT NOW, synchronously — the explicit entry for the two cases no
|
|
113
|
+
* scroll event covers: a page that did not fill the scrollport, and a virtualized list whose range
|
|
114
|
+
* reached its last row without the scroll position moving. Call it after a render.
|
|
115
|
+
*/
|
|
116
|
+
recheck(): void;
|
|
117
|
+
/**
|
|
118
|
+
* Report a new data version (a row count, a page id). Re-checks once when the value actually
|
|
119
|
+
* changed, so a wrapper can call it on every render: the page that just landed changed the version
|
|
120
|
+
* and the next one is requested even though nothing scrolled.
|
|
121
|
+
*
|
|
122
|
+
* @returns whether the value changed — and therefore whether a check just ran. A wrapper that also
|
|
123
|
+
* has other re-check reasons (a gate reopening) can use this to run exactly ONE check per commit
|
|
124
|
+
* instead of one per reason.
|
|
125
|
+
*/
|
|
126
|
+
setVersion(version: number): boolean;
|
|
127
|
+
/** Remove the listener, cancel the queued frame, drop the callback. Idempotent. */
|
|
128
|
+
dispose(): void;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The nearest element that actually scrolls `node`, at or above it.
|
|
132
|
+
*
|
|
133
|
+
* The criterion is the computed `overflow-y` (`auto`/`scroll`) AND "it really overflows"
|
|
134
|
+
* (`scrollHeight > clientHeight`): a host's wrapper elements are `overflow-y:auto` long before their
|
|
135
|
+
* content is taller than they are, and binding to one of those would listen to an element that never
|
|
136
|
+
* emits a scroll. A `null` result is therefore honest — nothing here scrolls — and the listener falls
|
|
137
|
+
* back to `window` (see {@link createAutoLoadMore}).
|
|
138
|
+
*
|
|
139
|
+
* `node` ITSELF is considered first. A panel normally passes its root (the scrollport is an ancestor:
|
|
140
|
+
* the host's conversation scroll body), but a list that owns its scroller — a virtualized list whose
|
|
141
|
+
* `getScrollElement()` is its own container — passes that container, which is the very element
|
|
142
|
+
* scroll events fire on. Scroll events do not bubble between scrollers, so getting THIS element (not
|
|
143
|
+
* some ancestor) is what makes such a list work.
|
|
144
|
+
*/
|
|
145
|
+
export declare function scrollParentOf(node: ScrollElement | null): ScrollElement | null;
|
|
146
|
+
/**
|
|
147
|
+
* Build the controller: listen for scroll (passive + one rAF per burst), check once immediately, and
|
|
148
|
+
* stay silent while `isEnabled()` is false.
|
|
149
|
+
*
|
|
150
|
+
* The immediate first check is not an optimisation: the first page of a short list does not fill the
|
|
151
|
+
* scrollport, so waiting for a scroll event means waiting forever.
|
|
152
|
+
*
|
|
153
|
+
* @param options - see {@link AutoLoadMoreOptions}. Read at creation: `scrollParentOf` runs once, so a
|
|
154
|
+
* panel that later gets its own scroller must dispose and recreate the controller.
|
|
155
|
+
*/
|
|
156
|
+
export declare function createAutoLoadMore(options: AutoLoadMoreOptions): AutoLoadMore;
|
|
157
|
+
//# sourceMappingURL=load_more.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"load_more.d.ts","sourceRoot":"","sources":["../../src/client/load_more.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AAEH,sFAAsF;AACtF,eAAO,MAAM,wBAAwB,MAAM,CAAA;AAE3C;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,aAAa,EAAE,aAAa,GAAG,IAAI,CAAA;IAC5C,QAAQ,CAAC,aAAa,EAAE,cAAc,GAAG,IAAI,CAAA;IAC7C,gBAAgB,CAAC,IAAI,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,IAAI,EAAE,OAAO,EAAE;QAAE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI,CAAA;IACpG,mBAAmB,CAAC,IAAI,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAAA;CAChE;AAED,sFAAsF;AACtF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,WAAW,EAAE,UAAU,GAAG,IAAI,CAAA;CACxC;AAED,yDAAyD;AACzD,MAAM,WAAW,UAAU;IACzB,gBAAgB,CAAC,OAAO,EAAE,aAAa,GAAG;QAAE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAA;CACzE;AAED,oGAAoG;AACpG,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,QAAQ,CAAC,MAAM,EAAE,aAAa,GAAG,IAAI,CAAA;IACrC;;;OAGG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,OAAO,CAAA;IACjC,mGAAmG;IACnG,QAAQ,CAAC,UAAU,EAAE,MAAM,IAAI,CAAA;IAC/B,+EAA+E;IAC/E,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB,iGAAiG;IACjG,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED,6FAA6F;AAC7F,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,OAAO,IAAI,IAAI,CAAA;IACf;;;;;;;;OAQG;IACH,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAA;IACpC,mFAAmF;IACnF,OAAO,IAAI,IAAI,CAAA;CAChB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,GAAG,aAAa,GAAG,IAAI,CAU/E;AA0DD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,GAAG,YAAY,CAsG7E"}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Scroll to the end → load the next page", as one DOM controller.
|
|
3
|
+
*
|
|
4
|
+
* **INTERNAL — no stability promise** (内部控制件,不承诺稳定): this module is reachable only through
|
|
5
|
+
* the `./internal` subpath, which the interface generation never covers (`docs/INTERFACE.md` §2/§5).
|
|
6
|
+
* It is not on `.`, it is not frozen with a generation, and a rename or a changed option shape is a
|
|
7
|
+
* base release, not a generation. Consumers (mem's 记忆 tab, mission's paged lists) follow the base.
|
|
8
|
+
*
|
|
9
|
+
* Why a DOM controller and not a React hook: this base has NO `react` peer (`package.json`), and it
|
|
10
|
+
* must stay that way — a Node user of the base must not be pulled into React, and the base ships no
|
|
11
|
+
* browser artifact. So the listener, the rAF throttle and the scrollport lookup live here, and each
|
|
12
|
+
* plugin keeps a thin wrapper of its own (mem: `useLoadMoreOnScroll`). The DOM detail then exists
|
|
13
|
+
* once instead of once per panel.
|
|
14
|
+
*
|
|
15
|
+
* Why the DOM types below are STRUCTURAL instead of `lib.dom`: the base compiles with `lib: es2023`
|
|
16
|
+
* and `types: node`, and adding `DOM` there retypes globals for the whole package (it breaks `fetch`
|
|
17
|
+
* bodies across the existing suite, for one). This module instead names the exact slice it reads —
|
|
18
|
+
* element, document, view — so the base stays node-only, its unit test needs no DOM environment, and
|
|
19
|
+
* a real `HTMLElement` / `Window` satisfies the interfaces as-is.
|
|
20
|
+
*
|
|
21
|
+
* Why a scroll listener and not an `IntersectionObserver` (three reasons measured in mem's panel):
|
|
22
|
+
*
|
|
23
|
+
* 1. **`rootMargin` cannot prefetch here.** A plugin panel usually owns no scrollport — the host's
|
|
24
|
+
* conversation scroll body does — and an ancestor's clip rect is NOT expanded by `rootMargin`
|
|
25
|
+
* (only the intersection root's is), so the promised look-ahead never happens.
|
|
26
|
+
* 2. **An observer only reports threshold CHANGES.** One page that failed to load leaves the
|
|
27
|
+
* sentinel visible, so no new callback arrives and auto-load stays dead until something else
|
|
28
|
+
* moves it. A scroll listener is level-triggered: the next scroll event simply tries again.
|
|
29
|
+
* 3. **The scrollport is found, not assumed.** {@link scrollParentOf} walks up to whatever element
|
|
30
|
+
* really scrolls, so this keeps working if a panel ever gets its own scroller.
|
|
31
|
+
*
|
|
32
|
+
* The gap the listener alone cannot close — and why {@link AutoLoadMore.recheck} exists: a page that
|
|
33
|
+
* does NOT fill the scrollport produces no scroll event, and neither does a virtualized list that
|
|
34
|
+
* reaches its last row (the virtualizer window moves, the scroll position does not). Callers
|
|
35
|
+
* therefore re-check after a render (`recheck`), and after the data version changes
|
|
36
|
+
* (`setVersion`, which re-checks only when the number really moved).
|
|
37
|
+
*
|
|
38
|
+
* **Where the controller stops (the host boundary).** {@link scrollParentOf} has exactly one
|
|
39
|
+
* criterion for a scrollport: the element's OWN computed `overflow-y` is `auto`/`scroll` AND its
|
|
40
|
+
* `scrollHeight > clientHeight`. Two things follow, and both are deliberate rather than bugs to
|
|
41
|
+
* paper over here:
|
|
42
|
+
*
|
|
43
|
+
* - **A host whose PAGE is the scroller silently does not auto-load.** With no element satisfying the
|
|
44
|
+
* criterion, {@link createAutoLoadMore} still attaches the listener to `window` (so nothing throws,
|
|
45
|
+
* and a scrollport that appears later is picked up), but `check()` refuses to fire while it has no
|
|
46
|
+
* scroller: there is no position to read, so the `window` listener alone changes nothing. The
|
|
47
|
+
* caller then owns the gap — call {@link AutoLoadMore.recheck} from its own render/commit path, or
|
|
48
|
+
* pass a real scrollport as `target` (e.g. `document.scrollingElement`, or the element whose
|
|
49
|
+
* `scrollHeight` the page grows).
|
|
50
|
+
* - **The scrollport is resolved once, at creation.** A panel that gains (or replaces) its own
|
|
51
|
+
* scroller later must `dispose()` and recreate the controller; `check` re-probes only while it has
|
|
52
|
+
* no scroller at all.
|
|
53
|
+
*
|
|
54
|
+
* @module client/load_more
|
|
55
|
+
*/
|
|
56
|
+
/** Distance from the scrollport's end, in px, at which the next page is requested. */
|
|
57
|
+
export const AUTO_LOAD_MORE_MARGIN_PX = 300;
|
|
58
|
+
/**
|
|
59
|
+
* The nearest element that actually scrolls `node`, at or above it.
|
|
60
|
+
*
|
|
61
|
+
* The criterion is the computed `overflow-y` (`auto`/`scroll`) AND "it really overflows"
|
|
62
|
+
* (`scrollHeight > clientHeight`): a host's wrapper elements are `overflow-y:auto` long before their
|
|
63
|
+
* content is taller than they are, and binding to one of those would listen to an element that never
|
|
64
|
+
* emits a scroll. A `null` result is therefore honest — nothing here scrolls — and the listener falls
|
|
65
|
+
* back to `window` (see {@link createAutoLoadMore}).
|
|
66
|
+
*
|
|
67
|
+
* `node` ITSELF is considered first. A panel normally passes its root (the scrollport is an ancestor:
|
|
68
|
+
* the host's conversation scroll body), but a list that owns its scroller — a virtualized list whose
|
|
69
|
+
* `getScrollElement()` is its own container — passes that container, which is the very element
|
|
70
|
+
* scroll events fire on. Scroll events do not bubble between scrollers, so getting THIS element (not
|
|
71
|
+
* some ancestor) is what makes such a list work.
|
|
72
|
+
*/
|
|
73
|
+
export function scrollParentOf(node) {
|
|
74
|
+
let current = node;
|
|
75
|
+
while (current !== null) {
|
|
76
|
+
const overflowY = computedOverflowY(current);
|
|
77
|
+
if ((overflowY === 'auto' || overflowY === 'scroll') && current.scrollHeight > current.clientHeight) {
|
|
78
|
+
return current;
|
|
79
|
+
}
|
|
80
|
+
current = current.parentElement;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The computed `overflow-y` of `element`, read off ITS OWN document's view.
|
|
86
|
+
*
|
|
87
|
+
* Not the bare `getComputedStyle` global: this module also runs under a test double or inside a
|
|
88
|
+
* detached document, and both need the style to come from somewhere explicit. An element with no
|
|
89
|
+
* view (no document, or a document without a window) reports `''` — "not a scrollport", the safe
|
|
90
|
+
* answer.
|
|
91
|
+
*/
|
|
92
|
+
function computedOverflowY(element) {
|
|
93
|
+
const view = element.ownerDocument?.defaultView;
|
|
94
|
+
if (view === null || view === undefined)
|
|
95
|
+
return '';
|
|
96
|
+
return view.getComputedStyle(element).overflowY;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Where to listen: the scrollport when one was found, else `window`.
|
|
100
|
+
*
|
|
101
|
+
* The `window` fallback is the case the seat's own history taught: a panel can be mounted in a
|
|
102
|
+
* document whose scrollport is the page itself. Listening there costs nothing, but the POSITION check
|
|
103
|
+
* still needs a scrollport, so nothing fires until an ancestor with its own scrollbar appears (or the
|
|
104
|
+
* caller drives {@link AutoLoadMore.recheck}). `null` only in a host without a `window` at all.
|
|
105
|
+
*/
|
|
106
|
+
function scrollEventSource(scroller) {
|
|
107
|
+
if (scroller !== null)
|
|
108
|
+
return scroller;
|
|
109
|
+
const view = globalThis['window'];
|
|
110
|
+
if (view === null || view === undefined || typeof view !== 'object')
|
|
111
|
+
return null;
|
|
112
|
+
const candidate = view;
|
|
113
|
+
if (typeof candidate.addEventListener !== 'function')
|
|
114
|
+
return null;
|
|
115
|
+
if (typeof candidate.removeEventListener !== 'function')
|
|
116
|
+
return null;
|
|
117
|
+
return view;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The host's animation-frame pair, read off `globalThis` (`lib.dom` is not in this package's types, and
|
|
121
|
+
* a node host has neither function — the controller then checks synchronously, its pre-rAF behaviour).
|
|
122
|
+
*/
|
|
123
|
+
function animationFrames() {
|
|
124
|
+
const host = globalThis;
|
|
125
|
+
const candidate = host;
|
|
126
|
+
if (typeof candidate.requestAnimationFrame !== 'function')
|
|
127
|
+
return null;
|
|
128
|
+
if (typeof candidate.cancelAnimationFrame !== 'function')
|
|
129
|
+
return null;
|
|
130
|
+
return host;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Build the controller: listen for scroll (passive + one rAF per burst), check once immediately, and
|
|
134
|
+
* stay silent while `isEnabled()` is false.
|
|
135
|
+
*
|
|
136
|
+
* The immediate first check is not an optimisation: the first page of a short list does not fill the
|
|
137
|
+
* scrollport, so waiting for a scroll event means waiting forever.
|
|
138
|
+
*
|
|
139
|
+
* @param options - see {@link AutoLoadMoreOptions}. Read at creation: `scrollParentOf` runs once, so a
|
|
140
|
+
* panel that later gets its own scroller must dispose and recreate the controller.
|
|
141
|
+
*/
|
|
142
|
+
export function createAutoLoadMore(options) {
|
|
143
|
+
const marginPx = options.marginPx ?? AUTO_LOAD_MORE_MARGIN_PX;
|
|
144
|
+
// Lazy on purpose: `scrollParentOf` only calls an element a scrollport when it is ALREADY taller
|
|
145
|
+
// than its viewport, and a controller is created before the first page of results is rendered — an
|
|
146
|
+
// empty (or short) result list answers "no scrollport" and would otherwise stay unscrollable
|
|
147
|
+
// forever. `check` re-probes and binds the moment an ancestor grows a scrollbar.
|
|
148
|
+
let scroller = scrollParentOf(options.target);
|
|
149
|
+
let source = scrollEventSource(scroller);
|
|
150
|
+
const frames = animationFrames();
|
|
151
|
+
let onLoadMore = options.onLoadMore;
|
|
152
|
+
let version = options.version ?? 0;
|
|
153
|
+
let disposed = false;
|
|
154
|
+
let queued = false;
|
|
155
|
+
let frame = null;
|
|
156
|
+
/** Bumped whenever a queued frame is superseded, so a callback the host still runs is a no-op. */
|
|
157
|
+
let generation = 0;
|
|
158
|
+
/** Move the listener onto `next`: the window fallback may be holding it, and a stale element listener
|
|
159
|
+
* would keep listening on a node that no longer scrolls. */
|
|
160
|
+
const bind = (next) => {
|
|
161
|
+
if (next === scroller)
|
|
162
|
+
return;
|
|
163
|
+
source?.removeEventListener('scroll', onScroll);
|
|
164
|
+
scroller = next;
|
|
165
|
+
source = scrollEventSource(next);
|
|
166
|
+
source?.addEventListener('scroll', onScroll, { passive: true });
|
|
167
|
+
};
|
|
168
|
+
const check = () => {
|
|
169
|
+
frame = null;
|
|
170
|
+
queued = false;
|
|
171
|
+
if (disposed || onLoadMore === null)
|
|
172
|
+
return;
|
|
173
|
+
if (scroller === null) {
|
|
174
|
+
const found = scrollParentOf(options.target);
|
|
175
|
+
if (found === null)
|
|
176
|
+
return;
|
|
177
|
+
bind(found);
|
|
178
|
+
}
|
|
179
|
+
if (scroller === null)
|
|
180
|
+
return;
|
|
181
|
+
if (!options.isEnabled())
|
|
182
|
+
return;
|
|
183
|
+
// Level-triggered: "the end is within reach" is a POSITION, not an edge, so a page that failed is
|
|
184
|
+
// retried by the next scroll event instead of waiting for a threshold crossing that never comes.
|
|
185
|
+
if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - marginPx)
|
|
186
|
+
onLoadMore();
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Drop the queued frame, if there is one, and make any callback already handed to the host inert.
|
|
190
|
+
*
|
|
191
|
+
* Called before a SYNC check (`recheck`/`setVersion`): the frame a scroll just scheduled reads the
|
|
192
|
+
* same position, so leaving it scheduled asks for the same page twice — the second `onLoadMore`
|
|
193
|
+
* arrives after the caller's own guard may already have cleared. The generation bump is belt and
|
|
194
|
+
* braces: a host (or a test) that runs a cancelled callback anyway must not ask again.
|
|
195
|
+
*/
|
|
196
|
+
const cancelQueued = () => {
|
|
197
|
+
if (frame !== null && frames !== null)
|
|
198
|
+
frames.cancelAnimationFrame(frame);
|
|
199
|
+
frame = null;
|
|
200
|
+
queued = false;
|
|
201
|
+
generation += 1;
|
|
202
|
+
};
|
|
203
|
+
const onScroll = () => {
|
|
204
|
+
if (disposed || queued)
|
|
205
|
+
return;
|
|
206
|
+
queued = true;
|
|
207
|
+
// One check per animation frame: a scroll burst is dozens of events, and each check may ask for a
|
|
208
|
+
// page — that must not happen dozens of times.
|
|
209
|
+
if (frames === null)
|
|
210
|
+
check();
|
|
211
|
+
else {
|
|
212
|
+
const scheduled = generation;
|
|
213
|
+
frame = frames.requestAnimationFrame(() => {
|
|
214
|
+
if (scheduled !== generation)
|
|
215
|
+
return; // superseded by a recheck/setVersion
|
|
216
|
+
check();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
source?.addEventListener('scroll', onScroll, { passive: true });
|
|
221
|
+
check(); // the first page may leave the scrollport unfilled
|
|
222
|
+
return {
|
|
223
|
+
recheck() {
|
|
224
|
+
if (disposed)
|
|
225
|
+
return;
|
|
226
|
+
// A frame is queued from a scroll that just happened: this synchronous check supersedes it.
|
|
227
|
+
cancelQueued();
|
|
228
|
+
check();
|
|
229
|
+
},
|
|
230
|
+
setVersion(next) {
|
|
231
|
+
if (disposed || next === version)
|
|
232
|
+
return false;
|
|
233
|
+
version = next;
|
|
234
|
+
// Same supersede rule as `recheck`: one commit, one check, one possible ask.
|
|
235
|
+
cancelQueued();
|
|
236
|
+
check();
|
|
237
|
+
return true;
|
|
238
|
+
},
|
|
239
|
+
dispose() {
|
|
240
|
+
if (disposed)
|
|
241
|
+
return;
|
|
242
|
+
disposed = true;
|
|
243
|
+
onLoadMore = null;
|
|
244
|
+
source?.removeEventListener('scroll', onScroll);
|
|
245
|
+
if (frame !== null && frames !== null)
|
|
246
|
+
frames.cancelAnimationFrame(frame);
|
|
247
|
+
frame = null;
|
|
248
|
+
queued = false;
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
//# sourceMappingURL=load_more.js.map
|
package/lib/internal.d.ts
CHANGED
|
@@ -43,6 +43,8 @@ export { NPM_PACKAGE_KIND, npmPackageProvider } from './providers/npm.js';
|
|
|
43
43
|
export type { NpmPackageSpec } from './providers/npm.js';
|
|
44
44
|
export { RUNTIME_DEPS_DIR, RUNTIME_DEPS_KIND, gcRuntimeDeps, runtimeDepsLockPath, runtimeDepsProvider, runtimeDepsStore, } from './providers/runtime_deps.js';
|
|
45
45
|
export type { DependencySource, InstallAttempt, RuntimeDepsAttemptRecord, RuntimeDepsGcOptions, RuntimeDepsGcReport, RuntimeDepsProviderOptions, RuntimeDepsRecord, RuntimeDepsRunInput, RuntimeDepsRunResult, RuntimeDepsSpec, } from './providers/runtime_deps.js';
|
|
46
|
+
export { AUTO_LOAD_MORE_MARGIN_PX, createAutoLoadMore, scrollParentOf } from './client/load_more.js';
|
|
47
|
+
export type { AutoLoadMore, AutoLoadMoreOptions, ScrollDocument, ScrollElement, ScrollView } from './client/load_more.js';
|
|
46
48
|
export { createPluginLogger } from './kit/logger.js';
|
|
47
49
|
export type { PluginLogger, PluginLoggerHost, PluginLoggerOptions } from './kit/logger.js';
|
|
48
50
|
export { endpointId, fieldSymbol, resultSymbol, strictCodec } from './kit/typert.js';
|
package/lib/internal.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"internal.d.ts","sourceRoot":"","sources":["../src/internal.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"internal.d.ts","sourceRoot":"","sources":["../src/internal.ts"],"names":[],"mappings":"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"}
|
package/lib/internal.js
CHANGED
|
@@ -42,6 +42,25 @@ export { NPM_PACKAGE_KIND, npmPackageProvider } from './providers/npm.js';
|
|
|
42
42
|
// The runtime-deps store's operator/maintenance half. The PROVIDER itself is on `.` (v5); GC, the
|
|
43
43
|
// store paths and the runner seams are explicit operator/test surface and stay off the interface.
|
|
44
44
|
export { RUNTIME_DEPS_DIR, RUNTIME_DEPS_KIND, gcRuntimeDeps, runtimeDepsLockPath, runtimeDepsProvider, runtimeDepsStore, } from './providers/runtime_deps.js';
|
|
45
|
+
// ── the browser-side kit ────────────────────────────────────────────────────────────────────────
|
|
46
|
+
// "Scroll to the end → load the next page", shared by the panels that page a list (mem's 记忆 tab,
|
|
47
|
+
// mission's paged lists). It lives HERE for two reasons, and neither changes the generation:
|
|
48
|
+
// `.` must not grow a browser-only member, and the base has no `react` peer, so the controller is
|
|
49
|
+
// plain DOM and each plugin keeps its own thin React wrapper. A client bundle INLINES this module (a
|
|
50
|
+
// browser cannot load the base at runtime), which is why it reads no node built-in and holds no
|
|
51
|
+
// shared runtime identity. Internal means internal: no compatibility promise, no generation entry,
|
|
52
|
+
// changeable in any base release.
|
|
53
|
+
export { AUTO_LOAD_MORE_MARGIN_PX, createAutoLoadMore, scrollParentOf } from './client/load_more.js';
|
|
54
|
+
// …and the shared CONTROL STYLES, on the same terms. Not a value export: a stylesheet is not a
|
|
55
|
+
// module this package can re-export, so it is its own subpath — `./internal/controls.module.css`,
|
|
56
|
+
// mapped to `src/client/controls.module.css` (source, shipped as-is; `controls_css.spec.ts` pins
|
|
57
|
+
// that). A plugin's client build writes
|
|
58
|
+
//
|
|
59
|
+
// import controls from '@avantf/dsh-plugin-base/internal/controls.module.css'
|
|
60
|
+
//
|
|
61
|
+
// and INLINES it: the CSS Modules pipeline hashes the classes from this file and injects one style
|
|
62
|
+
// tag, so the base still needs no browser artifact and no plugin fetches anything at runtime. mem's
|
|
63
|
+
// 记忆 tab is the first consumer; mission follows.
|
|
45
64
|
export { createPluginLogger } from './kit/logger.js';
|
|
46
65
|
export { endpointId, fieldSymbol, resultSymbol, strictCodec } from './kit/typert.js';
|
|
47
66
|
//# sourceMappingURL=internal.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avantf/dsh-plugin-base",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "The @avantf family base for DSH plugins: startup environment initialisation (declare → probe → fetch → verify → report), the host compatibility gate (provision / verdictOf / probes / report / post-registration verification), and one content-addressed shared store for runtime npm dependencies (runtimeDepsProvider). One package, loaded by the plugins through the inlined bootstrap.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"types": "./lib/internal.d.ts",
|
|
40
40
|
"default": "./lib/internal.js"
|
|
41
41
|
},
|
|
42
|
+
"./internal/controls.module.css": "./src/client/controls.module.css",
|
|
42
43
|
"./compat": {
|
|
43
44
|
"types": "./lib/compat.d.ts",
|
|
44
45
|
"default": "./lib/compat.js"
|
|
@@ -48,7 +49,8 @@
|
|
|
48
49
|
"files": [
|
|
49
50
|
"lib",
|
|
50
51
|
"!lib/*.js.map",
|
|
51
|
-
"!lib/**/*.js.map"
|
|
52
|
+
"!lib/**/*.js.map",
|
|
53
|
+
"src/client/*.module.css"
|
|
52
54
|
],
|
|
53
55
|
"sideEffects": false,
|
|
54
56
|
"peerDependencies": {
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared CONTROL styles for the avantf DSH plugin family — the one source.
|
|
3
|
+
*
|
|
4
|
+
* **INTERNAL — no stability promise** (内部控制件,不承诺稳定): reachable only through the
|
|
5
|
+
* `./internal/controls.module.css` subpath, which the interface generation never covers
|
|
6
|
+
* (`docs/INTERFACE.md` §2/§5). It is not on `.`, it is not frozen with a generation, and a renamed
|
|
7
|
+
* class is a base release, not a generation.
|
|
8
|
+
*
|
|
9
|
+
* Why a stylesheet and not components: this base has NO `react` peer and ships no browser artifact
|
|
10
|
+
* (`package.json`), and it must stay that way. So the family shares the STYLE LAYER and each plugin
|
|
11
|
+
* keeps its own markup (mem renders with `h()`, mission with its own view modules). A plugin's
|
|
12
|
+
* CLIENT BUILD inlines this file — the DSH client preset's CSS Modules loader compiles
|
|
13
|
+
* `<plugin>/src/client/*.module.css` into a hashed class map plus an injected `<style>` tag — which
|
|
14
|
+
* is why the file is published as SOURCE (`src/client/`, listed in `files`) and why nothing here
|
|
15
|
+
* reads the filesystem, imports another file, or assumes a build step in the base.
|
|
16
|
+
*
|
|
17
|
+
* The visual baseline is DSH's own design platform, not any one plugin:
|
|
18
|
+
*
|
|
19
|
+
* - every colour/radius is a `--dsw-*` token (`packages/client/ui-theme/src/styles/design-platform.css`
|
|
20
|
+
* for `--dsw-alias-*`, `base.css` for `--dsw-radius-*`); there is not a single colour literal here,
|
|
21
|
+
* which `test/controls_css.spec.ts` enforces;
|
|
22
|
+
* - the button is `ui-primitives`' Button (same box, same `sm` / `md` sizes, same
|
|
23
|
+
* primary / ghost / outline variants, same disabled opacity), with `active` taken from the
|
|
24
|
+
* `--dsw-alias-button-ghost-active-*` family (what a selected toggle looks like in the shell);
|
|
25
|
+
* - the field controls are the shell's input box (`ui-settings-models`' `.input`: 0.5px `border-l4`,
|
|
26
|
+
* `radius-md`, `bg-layer-1`, 14/22 type, focus turns the border to `state-business-primary`);
|
|
27
|
+
* - the card is the shell's settings material (`--dsw-alias-settings-card-fill/-stroke`, published
|
|
28
|
+
* on `body` by `ui-theme/src/styles/base.css`), at `radius-md` because plugin panels list rows,
|
|
29
|
+
* not settings sections.
|
|
30
|
+
*
|
|
31
|
+
* What is deliberately NOT here: page layout (columns, sticky bars, toolbars, dialogs) stays in each
|
|
32
|
+
* plugin, and so does anything that names a plugin's own semantics (status hues, warn/err boxes with
|
|
33
|
+
* a plugin-specific sentence). A plugin adds its own class for those; this file owns the shape.
|
|
34
|
+
*
|
|
35
|
+
* Consuming it (mem is the first consumer, mission follows):
|
|
36
|
+
*
|
|
37
|
+
* import controls from '@avantf/dsh-plugin-base/internal/controls.module.css'
|
|
38
|
+
* <button className={`${controls.button} ${controls.sm} ${controls.outline}`} />
|
|
39
|
+
*
|
|
40
|
+
* The class map is hashed per stylesheet by lightningcss (`[hash]_[local]`), so two plugins that
|
|
41
|
+
* inline this file never collide — and neither do two plugins' own stylesheets.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/* ── Buttons ─────────────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
* One geometry (`.button`) + one size (`sm` 28px / `md` 36px) + one variant (`primary` / `ghost` /
|
|
46
|
+
* `outline` / `danger`) + the selected state (`active`). Composed at the call site, exactly like the
|
|
47
|
+
* shell's `Button` composes its own classes.
|
|
48
|
+
*/
|
|
49
|
+
.button {
|
|
50
|
+
box-sizing: border-box;
|
|
51
|
+
display: inline-flex;
|
|
52
|
+
align-items: center;
|
|
53
|
+
justify-content: center;
|
|
54
|
+
gap: 4px;
|
|
55
|
+
padding: 0 14px;
|
|
56
|
+
border: none;
|
|
57
|
+
border-radius: var(--dsw-radius-md);
|
|
58
|
+
background: transparent;
|
|
59
|
+
color: var(--dsw-alias-label-primary);
|
|
60
|
+
font: inherit;
|
|
61
|
+
font-size: 14px;
|
|
62
|
+
line-height: 22px;
|
|
63
|
+
cursor: pointer;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.button:disabled {
|
|
67
|
+
cursor: not-allowed;
|
|
68
|
+
opacity: 0.4;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.md {
|
|
72
|
+
height: 36px;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.sm {
|
|
76
|
+
height: 28px;
|
|
77
|
+
padding: 0 10px;
|
|
78
|
+
border-radius: var(--dsw-radius-sm);
|
|
79
|
+
font-size: 12px;
|
|
80
|
+
line-height: 18px;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.primary {
|
|
84
|
+
background: var(--dsw-alias-button-primary-fill);
|
|
85
|
+
color: var(--dsw-alias-label-primary-foreground);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* The transparent border preserves the layout; the inset stroke stays complete at fractional widths. */
|
|
89
|
+
.outline {
|
|
90
|
+
border: 0.5px solid transparent;
|
|
91
|
+
background: transparent;
|
|
92
|
+
box-shadow: inset 0 0 0 var(--dsh-hairline-width, 0.5px) var(--dsw-alias-border-l3);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* The error colour is `state-error-primary`: it is the token `design-platform.css` DEFINES. (mem's
|
|
96
|
+
old rules read `--dsw-alias-label-error`, which that sheet does not define — the declaration was
|
|
97
|
+
invalid at computed-value time and the text simply inherited the row's colour.) */
|
|
98
|
+
.danger {
|
|
99
|
+
color: var(--dsw-alias-state-error-primary);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* A selected toggle: the shell's ghost-active material, so "this filter is on" reads the same here
|
|
103
|
+
as it does in the shell's own toolbars. */
|
|
104
|
+
.active {
|
|
105
|
+
background: var(--dsw-alias-button-ghost-active-fill);
|
|
106
|
+
box-shadow: inset 0 0 0 var(--dsh-hairline-width, 0.5px) var(--dsw-alias-button-ghost-active-border);
|
|
107
|
+
color: var(--dsw-alias-label-primary);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@media (hover: hover) {
|
|
111
|
+
.primary:hover:not(:disabled) {
|
|
112
|
+
background: var(--dsw-alias-button-primary-hover);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.ghost:hover:not(:disabled) {
|
|
116
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.outline:hover:not(:disabled) {
|
|
120
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.danger:hover:not(:disabled) {
|
|
124
|
+
background: var(--dsw-alias-interactive-bg-hover-danger);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.active:hover:not(:disabled) {
|
|
128
|
+
background: var(--dsw-alias-button-ghost-active-hover);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.ghost:active:not(:disabled) {
|
|
133
|
+
background: var(--dsw-alias-interactive-bg-active);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* ── Text roles ─────────────────────────────────────────────────────────────────────────────────── */
|
|
137
|
+
|
|
138
|
+
/* A neutral chip. A status TINT is a plugin's own class (its hue is the plugin's vocabulary); this
|
|
139
|
+
is the shape every tinted chip starts from. */
|
|
140
|
+
.badge {
|
|
141
|
+
display: inline-flex;
|
|
142
|
+
align-items: center;
|
|
143
|
+
gap: 4px;
|
|
144
|
+
padding: 0 6px;
|
|
145
|
+
border-radius: var(--dsw-radius-xs);
|
|
146
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
147
|
+
color: var(--dsw-alias-label-secondary);
|
|
148
|
+
font-size: 11px;
|
|
149
|
+
line-height: 18px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.list {
|
|
153
|
+
display: flex;
|
|
154
|
+
flex-direction: column;
|
|
155
|
+
gap: 8px;
|
|
156
|
+
margin: 0;
|
|
157
|
+
padding: 0;
|
|
158
|
+
list-style: none;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* The shell's settings-card material, one step tighter: plugin panels list rows, they are not
|
|
162
|
+
settings sections. */
|
|
163
|
+
.item {
|
|
164
|
+
padding: 10px 12px;
|
|
165
|
+
border: 0.5px solid var(--dsw-alias-settings-card-stroke);
|
|
166
|
+
border-radius: var(--dsw-radius-md);
|
|
167
|
+
background: var(--dsw-alias-settings-card-fill);
|
|
168
|
+
color: var(--dsw-alias-label-primary);
|
|
169
|
+
font-size: 13px;
|
|
170
|
+
line-height: 20px;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* Secondary line inside a row (an id, a count, a timestamp). `block` because it always follows the
|
|
174
|
+
row's primary line. */
|
|
175
|
+
.meta {
|
|
176
|
+
display: block;
|
|
177
|
+
margin-top: 2px;
|
|
178
|
+
font-size: 12px;
|
|
179
|
+
line-height: 18px;
|
|
180
|
+
color: var(--dsw-alias-label-tertiary);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* A whole-line status sentence: the same type as `.meta` without the block/margin behaviour. */
|
|
184
|
+
.status {
|
|
185
|
+
font-size: 12px;
|
|
186
|
+
line-height: 18px;
|
|
187
|
+
color: var(--dsw-alias-label-tertiary);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* A failure the reader must be able to copy out: pre-wrap so a multi-line host message keeps its
|
|
191
|
+
own line breaks. */
|
|
192
|
+
.error {
|
|
193
|
+
font-size: 12px;
|
|
194
|
+
line-height: 18px;
|
|
195
|
+
color: var(--dsw-alias-state-error-primary);
|
|
196
|
+
white-space: pre-wrap;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* ── Fields ───────────────────────────────────────────────────────────────────────────────────── */
|
|
200
|
+
|
|
201
|
+
/*
|
|
202
|
+
* The shared box. Width is NOT set here: a control sizes itself, and its container decides how wide
|
|
203
|
+
* it is (a field column stretches it, a toolbar row leaves it intrinsic). Every variant below adds
|
|
204
|
+
* only what makes it different.
|
|
205
|
+
*/
|
|
206
|
+
.input,
|
|
207
|
+
.textarea,
|
|
208
|
+
.select,
|
|
209
|
+
.dateInput {
|
|
210
|
+
box-sizing: border-box;
|
|
211
|
+
min-width: 0;
|
|
212
|
+
padding: 0 10px;
|
|
213
|
+
border: 0.5px solid var(--dsw-alias-border-l4);
|
|
214
|
+
border-radius: var(--dsw-radius-md);
|
|
215
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
216
|
+
color: var(--dsw-alias-label-primary);
|
|
217
|
+
font: inherit;
|
|
218
|
+
font-size: 14px;
|
|
219
|
+
line-height: 22px;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* Single-line controls share the shell's 32px control height. */
|
|
223
|
+
.input,
|
|
224
|
+
.select,
|
|
225
|
+
.dateInput {
|
|
226
|
+
height: 32px;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* The shell keeps the global `:focus-visible` ring and turns the border to the accent; it never
|
|
230
|
+
removes the ring, and neither does this. */
|
|
231
|
+
.input:focus-visible,
|
|
232
|
+
.textarea:focus-visible,
|
|
233
|
+
.select:focus-visible,
|
|
234
|
+
.dateInput:focus-visible {
|
|
235
|
+
border-color: var(--dsw-alias-state-business-primary);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.input::placeholder {
|
|
239
|
+
color: var(--dsw-alias-label-dimmed);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.input:disabled,
|
|
243
|
+
.textarea:disabled,
|
|
244
|
+
.select:disabled,
|
|
245
|
+
.dateInput:disabled {
|
|
246
|
+
opacity: 0.6;
|
|
247
|
+
cursor: default;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* Fills the field it is placed in; a textarea has no intrinsic width worth showing. */
|
|
251
|
+
.textarea {
|
|
252
|
+
width: 100%;
|
|
253
|
+
padding: 6px 10px;
|
|
254
|
+
resize: vertical;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.select {
|
|
258
|
+
cursor: pointer;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* A native date input: same box, digits lined up in the row (`2026-10-26` next to a `~` separator
|
|
262
|
+
must not jitter). */
|
|
263
|
+
.dateInput {
|
|
264
|
+
padding: 0 8px;
|
|
265
|
+
font-variant-numeric: tabular-nums;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/*
|
|
269
|
+
* The "clear this field" × — the shape only. A plugin places it (mem hangs it inside its search
|
|
270
|
+
* field); the button itself must not assume a positioned ancestor.
|
|
271
|
+
*/
|
|
272
|
+
.clear {
|
|
273
|
+
display: inline-flex;
|
|
274
|
+
align-items: center;
|
|
275
|
+
justify-content: center;
|
|
276
|
+
width: 20px;
|
|
277
|
+
height: 20px;
|
|
278
|
+
padding: 0;
|
|
279
|
+
border: none;
|
|
280
|
+
border-radius: 999px;
|
|
281
|
+
background: transparent;
|
|
282
|
+
color: var(--dsw-alias-label-tertiary);
|
|
283
|
+
font: inherit;
|
|
284
|
+
font-size: 14px;
|
|
285
|
+
line-height: 1;
|
|
286
|
+
cursor: pointer;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
@media (hover: hover) {
|
|
290
|
+
.clear:hover {
|
|
291
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
292
|
+
color: var(--dsw-alias-label-primary);
|
|
293
|
+
}
|
|
294
|
+
}
|