foldkit 0.74.1 → 0.75.1

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.
@@ -21,4 +21,5 @@ export * as Textarea from './textarea/public.js';
21
21
  export * as Tabs from './tabs/public.js';
22
22
  export * as Toast from './toast/public.js';
23
23
  export * as Tooltip from './tooltip/public.js';
24
+ export * as VirtualList from './virtualList/public.js';
24
25
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ui/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,SAAS,MAAM,uBAAuB,CAAA;AAClD,OAAO,KAAK,MAAM,MAAM,oBAAoB,CAAA;AAC5C,OAAO,KAAK,QAAQ,MAAM,sBAAsB,CAAA;AAChD,OAAO,KAAK,UAAU,MAAM,wBAAwB,CAAA;AACpD,OAAO,KAAK,QAAQ,MAAM,sBAAsB,CAAA;AAChD,OAAO,KAAK,QAAQ,MAAM,sBAAsB,CAAA;AAChD,OAAO,KAAK,MAAM,MAAM,oBAAoB,CAAA;AAC5C,OAAO,KAAK,WAAW,MAAM,yBAAyB,CAAA;AACtD,OAAO,KAAK,UAAU,MAAM,wBAAwB,CAAA;AACpD,OAAO,KAAK,QAAQ,MAAM,sBAAsB,CAAA;AAChD,OAAO,KAAK,QAAQ,MAAM,sBAAsB,CAAA;AAChD,OAAO,KAAK,KAAK,MAAM,mBAAmB,CAAA;AAC1C,OAAO,KAAK,OAAO,MAAM,qBAAqB,CAAA;AAC9C,OAAO,KAAK,IAAI,MAAM,kBAAkB,CAAA;AACxC,OAAO,KAAK,OAAO,MAAM,qBAAqB,CAAA;AAC9C,OAAO,KAAK,UAAU,MAAM,wBAAwB,CAAA;AACpD,OAAO,KAAK,MAAM,MAAM,oBAAoB,CAAA;AAC5C,OAAO,KAAK,MAAM,MAAM,oBAAoB,CAAA;AAC5C,OAAO,KAAK,MAAM,MAAM,oBAAoB,CAAA;AAC5C,OAAO,KAAK,QAAQ,MAAM,sBAAsB,CAAA;AAChD,OAAO,KAAK,IAAI,MAAM,kBAAkB,CAAA;AACxC,OAAO,KAAK,KAAK,MAAM,mBAAmB,CAAA;AAC1C,OAAO,KAAK,OAAO,MAAM,qBAAqB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ui/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,SAAS,MAAM,uBAAuB,CAAA;AAClD,OAAO,KAAK,MAAM,MAAM,oBAAoB,CAAA;AAC5C,OAAO,KAAK,QAAQ,MAAM,sBAAsB,CAAA;AAChD,OAAO,KAAK,UAAU,MAAM,wBAAwB,CAAA;AACpD,OAAO,KAAK,QAAQ,MAAM,sBAAsB,CAAA;AAChD,OAAO,KAAK,QAAQ,MAAM,sBAAsB,CAAA;AAChD,OAAO,KAAK,MAAM,MAAM,oBAAoB,CAAA;AAC5C,OAAO,KAAK,WAAW,MAAM,yBAAyB,CAAA;AACtD,OAAO,KAAK,UAAU,MAAM,wBAAwB,CAAA;AACpD,OAAO,KAAK,QAAQ,MAAM,sBAAsB,CAAA;AAChD,OAAO,KAAK,QAAQ,MAAM,sBAAsB,CAAA;AAChD,OAAO,KAAK,KAAK,MAAM,mBAAmB,CAAA;AAC1C,OAAO,KAAK,OAAO,MAAM,qBAAqB,CAAA;AAC9C,OAAO,KAAK,IAAI,MAAM,kBAAkB,CAAA;AACxC,OAAO,KAAK,OAAO,MAAM,qBAAqB,CAAA;AAC9C,OAAO,KAAK,UAAU,MAAM,wBAAwB,CAAA;AACpD,OAAO,KAAK,MAAM,MAAM,oBAAoB,CAAA;AAC5C,OAAO,KAAK,MAAM,MAAM,oBAAoB,CAAA;AAC5C,OAAO,KAAK,MAAM,MAAM,oBAAoB,CAAA;AAC5C,OAAO,KAAK,QAAQ,MAAM,sBAAsB,CAAA;AAChD,OAAO,KAAK,IAAI,MAAM,kBAAkB,CAAA;AACxC,OAAO,KAAK,KAAK,MAAM,mBAAmB,CAAA;AAC1C,OAAO,KAAK,OAAO,MAAM,qBAAqB,CAAA;AAC9C,OAAO,KAAK,WAAW,MAAM,yBAAyB,CAAA"}
package/dist/ui/index.js CHANGED
@@ -21,3 +21,4 @@ export * as Textarea from './textarea/public.js';
21
21
  export * as Tabs from './tabs/public.js';
22
22
  export * as Toast from './toast/public.js';
23
23
  export * as Tooltip from './tooltip/public.js';
24
+ export * as VirtualList from './virtualList/public.js';
@@ -0,0 +1,184 @@
1
+ import { Option, Schema as S } from 'effect';
2
+ import * as Command from '../../command/index.js';
3
+ import { type Attribute, type Html, type TagName } from '../../html/index.js';
4
+ /** Schema for the virtual list's state. Tracks scroll position, container
5
+ * measurement, and any in-flight programmatic scroll. */
6
+ export declare const Model: S.Struct<{
7
+ id: typeof S.String;
8
+ rowHeightPx: typeof S.Number;
9
+ scrollTop: typeof S.Number;
10
+ measurement: S.Union<[import("../../schema/index.js").CallableTaggedStruct<"Unmeasured", {}>, import("../../schema/index.js").CallableTaggedStruct<"Measured", {
11
+ containerHeight: typeof S.Number;
12
+ }>]>;
13
+ pendingScroll: S.Union<[import("../../schema/index.js").CallableTaggedStruct<"Idle", {}>, import("../../schema/index.js").CallableTaggedStruct<"ScrollingToIndex", {
14
+ index: typeof S.Number;
15
+ version: typeof S.Number;
16
+ }>]>;
17
+ pendingScrollVersion: typeof S.Number;
18
+ }>;
19
+ export type Model = typeof Model.Type;
20
+ /** Sent when the user scrolls the container. Carries the new scroll position
21
+ * read from the scroll event. */
22
+ export declare const ScrolledContainer: import("../../schema/index.js").CallableTaggedStruct<"ScrolledContainer", {
23
+ scrollTop: typeof S.Number;
24
+ }>;
25
+ /** Sent when the container resizes. Carries the new container height read
26
+ * from the `ResizeObserver` entry. */
27
+ export declare const MeasuredContainer: import("../../schema/index.js").CallableTaggedStruct<"MeasuredContainer", {
28
+ containerHeight: typeof S.Number;
29
+ }>;
30
+ /** Sent when a `scrollToIndex` Command completes. Carries the version it was
31
+ * issued with so the update can ignore stale completions. */
32
+ export declare const CompletedApplyScroll: import("../../schema/index.js").CallableTaggedStruct<"CompletedApplyScroll", {
33
+ version: typeof S.Number;
34
+ }>;
35
+ /** Union of all messages the virtual list component can produce. */
36
+ export declare const Message: S.Union<[
37
+ typeof ScrolledContainer,
38
+ typeof MeasuredContainer,
39
+ typeof CompletedApplyScroll
40
+ ]>;
41
+ export type ScrolledContainer = typeof ScrolledContainer.Type;
42
+ export type MeasuredContainer = typeof MeasuredContainer.Type;
43
+ export type Message = typeof Message.Type;
44
+ /** Configuration for creating a virtual list model with `init`. */
45
+ export type InitConfig = Readonly<{
46
+ id: string;
47
+ rowHeightPx: number;
48
+ initialScrollTop?: number;
49
+ }>;
50
+ /** Creates an initial virtual list model from a config. The container starts
51
+ * in `Unmeasured` state. The first `ResizeObserver` entry transitions it to
52
+ * `Measured`. */
53
+ export declare const init: (config: InitConfig) => Model;
54
+ export declare const ApplyScroll: Command.CommandDefinition<"ApplyScroll", {
55
+ readonly _tag: "CompletedApplyScroll";
56
+ readonly version: number;
57
+ }>;
58
+ /** Processes a virtual list message and returns the next model and commands. */
59
+ export declare const update: (model: Model, message: Message) => readonly [Model, ReadonlyArray<Command.Command<Message>>];
60
+ /** Programmatically scrolls the container so the row at `index` is visible.
61
+ * Returns the next model and a Command that mutates `element.scrollTop`. The
62
+ * natural scroll event then flows back through `ScrolledContainer` and the
63
+ * component re-renders the new visible slice.
64
+ *
65
+ * Uses version-based cancellation: each call increments
66
+ * `pendingScrollVersion` so a stale `CompletedApplyScroll` (e.g. from a
67
+ * previous in-flight scroll) is ignored when its version no longer matches.
68
+ *
69
+ * Should be called after the container has rendered. If the container is not
70
+ * yet in the DOM the Command silently no-ops (the model still transitions
71
+ * through `ScrollingToIndex` → `Idle` via the version-matched completion). */
72
+ export declare const scrollToIndex: (model: Model, index: number) => readonly [Model, ReadonlyArray<Command.Command<Message>>];
73
+ /** Slice of the data array that the view should render, plus the spacer
74
+ * heights that keep the scrollbar physically correct. The first row in the
75
+ * slice corresponds to data index `startIndex`. */
76
+ export type VisibleWindow = Readonly<{
77
+ startIndex: number;
78
+ endIndex: number;
79
+ topSpacerHeight: number;
80
+ bottomSpacerHeight: number;
81
+ }>;
82
+ /** Computes the visible slice of a data array given the current scroll
83
+ * position, container height, row height, and an overscan buffer.
84
+ *
85
+ * Returns `Option.none()` when the container has not yet been measured;
86
+ * callers should render a placeholder (or `Html.empty`) and wait for the
87
+ * first `MeasuredContainer` message. */
88
+ export declare const visibleWindow: (model: Model, itemCount: number, overscan: number) => Option.Option<VisibleWindow>;
89
+ /** Schema describing the subscription dependencies for container scroll and
90
+ * resize tracking. */
91
+ export declare const SubscriptionDeps: S.Struct<{
92
+ containerEvents: S.Struct<{
93
+ id: typeof S.String;
94
+ }>;
95
+ }>;
96
+ /** Subscriptions that track the container's scroll position and size.
97
+ *
98
+ * - **scroll**: listens for `scroll` events on the container element and
99
+ * emits `ScrolledContainer` with the new `scrollTop`.
100
+ * - **resize**: observes the container with `ResizeObserver` and emits
101
+ * `MeasuredContainer` with the new height.
102
+ *
103
+ * A `MutationObserver` watches the document for the container element
104
+ * appearing and disappearing, so the listeners attach the moment the
105
+ * element is inserted into the DOM and clean up when it is removed. This
106
+ * makes the subscription robust across SPA route changes: navigating to a
107
+ * page that mounts the list, away, and back all reattach correctly without
108
+ * the consumer having to teach the framework about navigation. */
109
+ export declare const subscriptions: import("../../runtime/subscription.js").Subscriptions<{
110
+ readonly id: string;
111
+ readonly rowHeightPx: number;
112
+ readonly scrollTop: number;
113
+ readonly measurement: {
114
+ readonly _tag: "Unmeasured";
115
+ } | {
116
+ readonly _tag: "Measured";
117
+ readonly containerHeight: number;
118
+ };
119
+ readonly pendingScroll: {
120
+ readonly _tag: "Idle";
121
+ } | {
122
+ readonly _tag: "ScrollingToIndex";
123
+ readonly index: number;
124
+ readonly version: number;
125
+ };
126
+ readonly pendingScrollVersion: number;
127
+ }, {
128
+ readonly _tag: "ScrolledContainer";
129
+ readonly scrollTop: number;
130
+ } | {
131
+ readonly _tag: "MeasuredContainer";
132
+ readonly containerHeight: number;
133
+ } | {
134
+ readonly _tag: "CompletedApplyScroll";
135
+ readonly version: number;
136
+ }, S.Struct<{
137
+ containerEvents: S.Struct<{
138
+ id: typeof S.String;
139
+ }>;
140
+ }>, never>;
141
+ /** Configuration for rendering a virtual list with `view`.
142
+ *
143
+ * VirtualList does not take a `toParentMessage` callback. All input
144
+ * (scroll events and resize observations) flows through the
145
+ * `containerEvents` Subscription, not through view-bound handlers.
146
+ * Consumers wrap the subscription's stream into their parent Message in
147
+ * their own `subscriptions` definition. */
148
+ export type ViewConfig<Message, Item> = Readonly<{
149
+ model: Model;
150
+ items: ReadonlyArray<Item>;
151
+ itemToKey: (item: Item, index: number) => string;
152
+ itemToView: (item: Item, index: number) => Html;
153
+ /** Number of rows rendered above and below the visible viewport. Higher
154
+ * values smooth out fast scroll at the cost of mounting more DOM. Default
155
+ * is 5; react-window uses 1 and react-virtualized uses 3. Pick a value
156
+ * that suits the row's mount cost. */
157
+ overscan?: number;
158
+ rowElement?: TagName;
159
+ className?: string;
160
+ attributes?: ReadonlyArray<Attribute<Message>>;
161
+ }>;
162
+ /** Renders a virtualized list. Only items inside the viewport (plus an
163
+ * overscan buffer) are mounted; spacer elements above and below the slice
164
+ * keep the scrollbar's apparent total height correct.
165
+ *
166
+ * Items must be keyed via `itemToKey` so the VDOM matches `row 150` to
167
+ * `row 150` after the slice shifts during scroll, rather than matching by
168
+ * position and producing stale DOM.
169
+ *
170
+ * Each row wrapper is rendered with `display: grid` so the consumer's
171
+ * `itemToView` content fills the configured `rowHeightPx` and the full row
172
+ * width. Use flex/grid with `align-items: center` inside `itemToView` to
173
+ * vertically center content within the row.
174
+ *
175
+ * Each row carries `aria-setsize` (total item count) and `aria-posinset`
176
+ * (1-based logical row index) so screen readers announce the full list
177
+ * size and each row's position within it, rather than the much smaller
178
+ * count of currently mounted rows. */
179
+ export declare const view: <Message, Item>(config: ViewConfig<Message, Item>) => Html;
180
+ /** Creates a memoized virtual list view. Static config is captured in a
181
+ * closure; only `model` and `items` are compared per render via
182
+ * `createLazy`. */
183
+ export declare const lazy: <Message, Item>(staticConfig: Omit<ViewConfig<Message, Item>, "model" | "items">) => ((model: Model, items: ReadonlyArray<Item>) => Html);
184
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/ui/virtualList/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,326 @@
1
+ import { Array, Effect, Match as M, Number, Option, Schema as S, Stream, } from 'effect';
2
+ import * as Command from '../../command/index.js';
3
+ import { createLazy, html, } from '../../html/index.js';
4
+ import { m } from '../../message/index.js';
5
+ import { makeSubscriptions } from '../../runtime/subscription.js';
6
+ import { ts } from '../../schema/index.js';
7
+ import { evo } from '../../struct/index.js';
8
+ // MODEL
9
+ const Unmeasured = ts('Unmeasured');
10
+ const Measured = ts('Measured', { containerHeight: S.Number });
11
+ /** Measurement state of the virtual list's scrollable container.
12
+ *
13
+ * Before the container's `ResizeObserver` fires for the first time we don't
14
+ * know its height and cannot compute a visible slice. The view must handle
15
+ * `Unmeasured` explicitly, typically by rendering a placeholder until the
16
+ * first measurement arrives.
17
+ */
18
+ const Measurement = S.Union(Unmeasured, Measured);
19
+ const Idle = ts('Idle');
20
+ const ScrollingToIndex = ts('ScrollingToIndex', {
21
+ index: S.Number,
22
+ version: S.Number,
23
+ });
24
+ /** State of a programmatic scroll initiated by `scrollToIndex`. */
25
+ const PendingScroll = S.Union(Idle, ScrollingToIndex);
26
+ /** Schema for the virtual list's state. Tracks scroll position, container
27
+ * measurement, and any in-flight programmatic scroll. */
28
+ export const Model = S.Struct({
29
+ id: S.String,
30
+ rowHeightPx: S.Number,
31
+ scrollTop: S.Number,
32
+ measurement: Measurement,
33
+ pendingScroll: PendingScroll,
34
+ pendingScrollVersion: S.Number,
35
+ });
36
+ // MESSAGE
37
+ /** Sent when the user scrolls the container. Carries the new scroll position
38
+ * read from the scroll event. */
39
+ export const ScrolledContainer = m('ScrolledContainer', {
40
+ scrollTop: S.Number,
41
+ });
42
+ /** Sent when the container resizes. Carries the new container height read
43
+ * from the `ResizeObserver` entry. */
44
+ export const MeasuredContainer = m('MeasuredContainer', {
45
+ containerHeight: S.Number,
46
+ });
47
+ /** Sent when a `scrollToIndex` Command completes. Carries the version it was
48
+ * issued with so the update can ignore stale completions. */
49
+ export const CompletedApplyScroll = m('CompletedApplyScroll', {
50
+ version: S.Number,
51
+ });
52
+ /** Union of all messages the virtual list component can produce. */
53
+ export const Message = S.Union(ScrolledContainer, MeasuredContainer, CompletedApplyScroll);
54
+ /** Creates an initial virtual list model from a config. The container starts
55
+ * in `Unmeasured` state. The first `ResizeObserver` entry transitions it to
56
+ * `Measured`. */
57
+ export const init = (config) => ({
58
+ id: config.id,
59
+ rowHeightPx: config.rowHeightPx,
60
+ scrollTop: config.initialScrollTop ?? 0,
61
+ measurement: Unmeasured(),
62
+ pendingScroll: Idle(),
63
+ pendingScrollVersion: 0,
64
+ });
65
+ // UPDATE
66
+ export const ApplyScroll = Command.define('ApplyScroll', CompletedApplyScroll);
67
+ const applyScroll = (id, scrollTop, version) => ApplyScroll(Effect.sync(() => {
68
+ const element = document.getElementById(id);
69
+ if (element !== null) {
70
+ element.scrollTop = scrollTop;
71
+ }
72
+ return CompletedApplyScroll({ version });
73
+ }));
74
+ /** Processes a virtual list message and returns the next model and commands. */
75
+ export const update = (model, message) => M.value(message).pipe(M.withReturnType(), M.tagsExhaustive({
76
+ ScrolledContainer: ({ scrollTop }) => [
77
+ evo(model, { scrollTop: () => scrollTop }),
78
+ [],
79
+ ],
80
+ MeasuredContainer: ({ containerHeight }) => {
81
+ const wasUnmeasured = model.measurement._tag === 'Unmeasured';
82
+ const needsInitialApply = wasUnmeasured && model.scrollTop !== 0;
83
+ if (needsInitialApply) {
84
+ const nextVersion = Number.increment(model.pendingScrollVersion);
85
+ return [
86
+ evo(model, {
87
+ measurement: () => Measured({ containerHeight }),
88
+ pendingScrollVersion: () => nextVersion,
89
+ pendingScroll: () => ScrollingToIndex({
90
+ index: Math.floor(model.scrollTop / model.rowHeightPx),
91
+ version: nextVersion,
92
+ }),
93
+ }),
94
+ [applyScroll(model.id, model.scrollTop, nextVersion)],
95
+ ];
96
+ }
97
+ else {
98
+ return [
99
+ evo(model, { measurement: () => Measured({ containerHeight }) }),
100
+ [],
101
+ ];
102
+ }
103
+ },
104
+ CompletedApplyScroll: ({ version }) => {
105
+ if (version !== model.pendingScrollVersion) {
106
+ return [model, []];
107
+ }
108
+ else {
109
+ return [evo(model, { pendingScroll: () => Idle() }), []];
110
+ }
111
+ },
112
+ }));
113
+ /** Programmatically scrolls the container so the row at `index` is visible.
114
+ * Returns the next model and a Command that mutates `element.scrollTop`. The
115
+ * natural scroll event then flows back through `ScrolledContainer` and the
116
+ * component re-renders the new visible slice.
117
+ *
118
+ * Uses version-based cancellation: each call increments
119
+ * `pendingScrollVersion` so a stale `CompletedApplyScroll` (e.g. from a
120
+ * previous in-flight scroll) is ignored when its version no longer matches.
121
+ *
122
+ * Should be called after the container has rendered. If the container is not
123
+ * yet in the DOM the Command silently no-ops (the model still transitions
124
+ * through `ScrollingToIndex` → `Idle` via the version-matched completion). */
125
+ export const scrollToIndex = (model, index) => {
126
+ const nextVersion = Number.increment(model.pendingScrollVersion);
127
+ const targetScrollTop = index * model.rowHeightPx;
128
+ return [
129
+ evo(model, {
130
+ pendingScrollVersion: () => nextVersion,
131
+ pendingScroll: () => ScrollingToIndex({ index, version: nextVersion }),
132
+ }),
133
+ [applyScroll(model.id, targetScrollTop, nextVersion)],
134
+ ];
135
+ };
136
+ const clampIndex = (index, itemCount) => Math.max(0, Math.min(index, itemCount));
137
+ /** Computes the visible slice of a data array given the current scroll
138
+ * position, container height, row height, and an overscan buffer.
139
+ *
140
+ * Returns `Option.none()` when the container has not yet been measured;
141
+ * callers should render a placeholder (or `Html.empty`) and wait for the
142
+ * first `MeasuredContainer` message. */
143
+ export const visibleWindow = (model, itemCount, overscan) => M.value(model.measurement).pipe(M.withReturnType(), M.tagsExhaustive({
144
+ Unmeasured: () => Option.none(),
145
+ Measured: ({ containerHeight }) => {
146
+ const firstVisibleIndex = Math.floor(model.scrollTop / model.rowHeightPx);
147
+ const lastVisibleIndex = Math.ceil((model.scrollTop + containerHeight) / model.rowHeightPx);
148
+ const startIndex = clampIndex(firstVisibleIndex - overscan, itemCount);
149
+ const endIndex = clampIndex(lastVisibleIndex + overscan, itemCount);
150
+ const topSpacerHeight = startIndex * model.rowHeightPx;
151
+ const bottomSpacerHeight = (itemCount - endIndex) * model.rowHeightPx;
152
+ return Option.some({
153
+ startIndex,
154
+ endIndex,
155
+ topSpacerHeight,
156
+ bottomSpacerHeight,
157
+ });
158
+ },
159
+ }));
160
+ // SUBSCRIPTION
161
+ const containerElement = (id) => Option.fromNullable(document.getElementById(id));
162
+ /** Schema describing the subscription dependencies for container scroll and
163
+ * resize tracking. */
164
+ export const SubscriptionDeps = S.Struct({
165
+ containerEvents: S.Struct({
166
+ id: S.String,
167
+ }),
168
+ });
169
+ /** Subscriptions that track the container's scroll position and size.
170
+ *
171
+ * - **scroll**: listens for `scroll` events on the container element and
172
+ * emits `ScrolledContainer` with the new `scrollTop`.
173
+ * - **resize**: observes the container with `ResizeObserver` and emits
174
+ * `MeasuredContainer` with the new height.
175
+ *
176
+ * A `MutationObserver` watches the document for the container element
177
+ * appearing and disappearing, so the listeners attach the moment the
178
+ * element is inserted into the DOM and clean up when it is removed. This
179
+ * makes the subscription robust across SPA route changes: navigating to a
180
+ * page that mounts the list, away, and back all reattach correctly without
181
+ * the consumer having to teach the framework about navigation. */
182
+ export const subscriptions = makeSubscriptions(SubscriptionDeps)({
183
+ containerEvents: {
184
+ modelToDependencies: model => ({ id: model.id }),
185
+ dependenciesToStream: ({ id }) => Stream.async(emit => {
186
+ let scrollListener = null;
187
+ let resizeObserver = null;
188
+ let observedElement = null;
189
+ let pendingFrame = null;
190
+ const detach = () => {
191
+ if (resizeObserver !== null) {
192
+ resizeObserver.disconnect();
193
+ resizeObserver = null;
194
+ }
195
+ if (observedElement !== null && scrollListener !== null) {
196
+ observedElement.removeEventListener('scroll', scrollListener);
197
+ }
198
+ observedElement = null;
199
+ scrollListener = null;
200
+ };
201
+ const attach = (element) => {
202
+ const listener = () => emit.single(ScrolledContainer({ scrollTop: element.scrollTop }));
203
+ element.addEventListener('scroll', listener, { passive: true });
204
+ scrollListener = listener;
205
+ observedElement = element;
206
+ resizeObserver = new ResizeObserver(entries => {
207
+ const lastEntry = Array.last(entries);
208
+ if (Option.isSome(lastEntry)) {
209
+ emit.single(MeasuredContainer({
210
+ containerHeight: lastEntry.value.contentRect.height,
211
+ }));
212
+ }
213
+ });
214
+ resizeObserver.observe(element);
215
+ };
216
+ const reconcile = () => {
217
+ const maybeElement = containerElement(id);
218
+ if (Option.isNone(maybeElement)) {
219
+ if (observedElement !== null) {
220
+ detach();
221
+ }
222
+ return;
223
+ }
224
+ if (observedElement === maybeElement.value) {
225
+ return;
226
+ }
227
+ detach();
228
+ attach(maybeElement.value);
229
+ };
230
+ reconcile();
231
+ // NOTE: observes the entire document subtree because the container
232
+ // can be inserted/removed by any parent the consumer chooses (route
233
+ // changes, conditional renders, modal mounts), and the framework has
234
+ // no way to know that hierarchy in advance. Reconcile is gated by rAF
235
+ // and short-circuits when the cached observedElement is still in the
236
+ // DOM, so per-mutation cost stays low even with subtree: true.
237
+ const mutationObserver = new MutationObserver(() => {
238
+ if (pendingFrame !== null) {
239
+ return;
240
+ }
241
+ pendingFrame = requestAnimationFrame(() => {
242
+ pendingFrame = null;
243
+ reconcile();
244
+ });
245
+ });
246
+ mutationObserver.observe(document.body, {
247
+ childList: true,
248
+ subtree: true,
249
+ });
250
+ return Effect.sync(() => {
251
+ mutationObserver.disconnect();
252
+ if (pendingFrame !== null) {
253
+ cancelAnimationFrame(pendingFrame);
254
+ }
255
+ detach();
256
+ });
257
+ }),
258
+ },
259
+ });
260
+ // VIEW
261
+ const DEFAULT_OVERSCAN = 5;
262
+ /** Renders a virtualized list. Only items inside the viewport (plus an
263
+ * overscan buffer) are mounted; spacer elements above and below the slice
264
+ * keep the scrollbar's apparent total height correct.
265
+ *
266
+ * Items must be keyed via `itemToKey` so the VDOM matches `row 150` to
267
+ * `row 150` after the slice shifts during scroll, rather than matching by
268
+ * position and producing stale DOM.
269
+ *
270
+ * Each row wrapper is rendered with `display: grid` so the consumer's
271
+ * `itemToView` content fills the configured `rowHeightPx` and the full row
272
+ * width. Use flex/grid with `align-items: center` inside `itemToView` to
273
+ * vertically center content within the row.
274
+ *
275
+ * Each row carries `aria-setsize` (total item count) and `aria-posinset`
276
+ * (1-based logical row index) so screen readers announce the full list
277
+ * size and each row's position within it, rather than the much smaller
278
+ * count of currently mounted rows. */
279
+ export const view = (config) => {
280
+ const { AriaPosinset, AriaSetsize, Class, DataAttribute, Id, Role, Style, keyed, } = html();
281
+ const { model, items, itemToKey, itemToView, overscan = DEFAULT_OVERSCAN, rowElement = 'li', className, attributes = [], } = config;
282
+ const containerAttributes = [
283
+ Id(model.id),
284
+ Role('list'),
285
+ DataAttribute('virtual-list-id', model.id),
286
+ Style({
287
+ overflow: 'auto',
288
+ 'list-style': 'none',
289
+ margin: '0',
290
+ padding: '0',
291
+ }),
292
+ ...(className !== undefined ? [Class(className)] : []),
293
+ ...attributes,
294
+ ];
295
+ const renderContainer = (children) => keyed('ul')(model.id, containerAttributes, children);
296
+ return Option.match(visibleWindow(model, items.length, overscan), {
297
+ onNone: () => renderContainer([]),
298
+ onSome: ({ startIndex, endIndex, topSpacerHeight, bottomSpacerHeight }) => {
299
+ const visibleItems = items.slice(startIndex, endIndex);
300
+ const topSpacer = keyed('li')(`${model.id}-top-spacer`, [Role('presentation'), Style({ height: `${topSpacerHeight}px` })], []);
301
+ const bottomSpacer = keyed('li')(`${model.id}-bottom-spacer`, [Role('presentation'), Style({ height: `${bottomSpacerHeight}px` })], []);
302
+ const renderedRows = Array.map(visibleItems, (item, sliceIndex) => {
303
+ const dataIndex = startIndex + sliceIndex;
304
+ return keyed(rowElement)(itemToKey(item, dataIndex), [
305
+ Role('listitem'),
306
+ DataAttribute('virtual-list-item-index', String(dataIndex)),
307
+ AriaSetsize(items.length),
308
+ AriaPosinset(dataIndex + 1),
309
+ Style({ height: `${model.rowHeightPx}px`, display: 'grid' }),
310
+ ], [itemToView(item, dataIndex)]);
311
+ });
312
+ return renderContainer([topSpacer, ...renderedRows, bottomSpacer]);
313
+ },
314
+ });
315
+ };
316
+ /** Creates a memoized virtual list view. Static config is captured in a
317
+ * closure; only `model` and `items` are compared per render via
318
+ * `createLazy`. */
319
+ export const lazy = (staticConfig) => {
320
+ const lazyView = createLazy();
321
+ return (model, items) => lazyView((currentModel, currentItems) => view({
322
+ ...staticConfig,
323
+ model: currentModel,
324
+ items: currentItems,
325
+ }), [model, items]);
326
+ };
@@ -0,0 +1,3 @@
1
+ export { init, update, scrollToIndex, view, lazy, subscriptions, visibleWindow, Model, Message, ScrolledContainer, MeasuredContainer, CompletedApplyScroll, SubscriptionDeps, } from './index.js';
2
+ export type { InitConfig, ViewConfig, VisibleWindow } from './index.js';
3
+ //# sourceMappingURL=public.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"public.d.ts","sourceRoot":"","sources":["../../../src/ui/virtualList/public.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,IAAI,EACJ,MAAM,EACN,aAAa,EACb,IAAI,EACJ,IAAI,EACJ,aAAa,EACb,aAAa,EACb,KAAK,EACL,OAAO,EACP,iBAAiB,EACjB,iBAAiB,EACjB,oBAAoB,EACpB,gBAAgB,GACjB,MAAM,YAAY,CAAA;AAEnB,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA"}
@@ -0,0 +1 @@
1
+ export { init, update, scrollToIndex, view, lazy, subscriptions, visibleWindow, Model, Message, ScrolledContainer, MeasuredContainer, CompletedApplyScroll, SubscriptionDeps, } from './index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foldkit",
3
- "version": "0.74.1",
3
+ "version": "0.75.1",
4
4
  "description": "A frontend framework for TypeScript, built on Effect, using The Elm Architecture",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -159,6 +159,10 @@
159
159
  "types": "./dist/ui/tooltip/public.d.ts",
160
160
  "import": "./dist/ui/tooltip/public.js"
161
161
  },
162
+ "./ui/virtualList": {
163
+ "types": "./dist/ui/virtualList/public.d.ts",
164
+ "import": "./dist/ui/virtualList/public.js"
165
+ },
162
166
  "./url": {
163
167
  "types": "./dist/url/public.d.ts",
164
168
  "import": "./dist/url/public.js"