create-foldkit-app 0.35.0 → 0.35.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.
- package/dist/templates/base/FOLDKIT.md +3 -1
- package/dist/templates/examples/generative-art/src/subscription.ts +1 -1
- package/dist/templates/examples/job-application/src/message.ts +0 -12
- package/dist/templates/examples/job-application/src/story.test.ts +0 -11
- package/dist/templates/examples/job-application/src/update.ts +0 -4
- package/dist/templates/examples/pixel-art/src/scene.test.ts +27 -7
- package/dist/templates/examples/pixel-art/src/story.test.ts +2 -3
- package/dist/templates/examples/pixel-art/src/subscription.ts +2 -2
- package/dist/templates/examples/routing/src/entry.ts +2 -1
- package/dist/templates/examples/routing/src/main.ts +56 -9
- package/dist/templates/examples/routing/src/story.test.ts +53 -1
- package/dist/templates/examples/slow-warnings/src/main.ts +4 -5
- package/dist/templates/examples/snake/src/main.ts +1 -1
- package/dist/templates/examples/ui-showcase/src/main.ts +4 -11
- package/dist/templates/examples/ui-showcase/src/scene.test.ts +13 -1
- package/dist/templates/examples/ui-showcase/src/story.test.ts +2 -3
- package/dist/templates/examples/ui-showcase/src/ui/init.ts +1 -0
- package/dist/templates/examples/ui-showcase/src/ui/message.ts +1 -0
- package/dist/templates/examples/ui-showcase/src/ui/model.ts +1 -0
- package/dist/templates/examples/ui-showcase/src/ui/subscriptions.ts +1 -1
- package/dist/templates/examples/ui-showcase/src/ui/update.ts +6 -0
- package/dist/templates/examples/ui-showcase/src/ui/view/disclosure.ts +110 -0
- package/dist/templates/release.json +13 -13
- package/package.json +1 -1
|
@@ -76,7 +76,7 @@ Pass optional Commands directly to APIs that accept them: `Command.mapMessages(h
|
|
|
76
76
|
|
|
77
77
|
Use `Update.Return<Model, Message>` when an update cannot emit an OutMessage. It prevents a result containing an OutMessage from entering code that would keep only its Model and Commands. A result with no `outMessage` can still be used where `Update.ReturnWithOutMessage<Model, Message, OutMessage>` is expected. The missing field means that update emitted no OutMessage.
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
Use `Update.foldChildInit` for one child `init` or `boot` result. Use `Update.foldChildInits` when several child results enter one parent Model. Both lift child Commands and handle child OutMessages. Use `Update.foldChild` for a child update that receives input, or `Update.foldChildStep` for a child helper that receives only its Model. Keep route-gated initialization or Model-only child construction separate when there is no shared set of child results to fold.
|
|
80
80
|
|
|
81
81
|
Use `Update.combine` when a later Step should receive the Model produced by an earlier Step. It takes two or more Steps. Do not wrap one Step in `Update.combine`; call that operation directly. Name an inline Step parameter `stepModel`; it contains the Model produced by the preceding Step.
|
|
82
82
|
|
|
@@ -84,6 +84,8 @@ When the OutMessage is already known while constructing a new result, include it
|
|
|
84
84
|
|
|
85
85
|
Add `toParentOutMessage` only when at least one child OutMessage should continue to the current Submodel's parent. For partial forwarding, match every child variant and return `undefined` for the variants that stop here. Omit `toParentOutMessage` when every variant stops here. `foldOutMessage` still handles each variant locally, including variants that continue upward. Never write `toParentOutMessage: () => undefined`.
|
|
86
86
|
|
|
87
|
+
When a `foldChildInits` entry can derive or forward an OutMessage, add `resolveOutMessage` to construct one parent OutMessage from the named OutMessages after every local fold completes. Combine their information when both results matter; choosing one discards the other. The callback also receives the final Model. If that Model alone contains everything needed, use local folds and attach a parent OutMessage afterward with `Update.withOutMessage`.
|
|
88
|
+
|
|
87
89
|
Use `evo()` from `foldkit/struct` for immutable model updates. Never spread or `Object.assign`.
|
|
88
90
|
|
|
89
91
|
### View
|
|
@@ -28,7 +28,7 @@ const frameSubscription = Subscription.make<Model, Message>()(_entry => ({
|
|
|
28
28
|
}),
|
|
29
29
|
}))
|
|
30
30
|
|
|
31
|
-
export const subscriptions = Subscription.aggregate
|
|
31
|
+
export const subscriptions = Subscription.aggregate(
|
|
32
32
|
frameSubscription,
|
|
33
33
|
flowStrengthSliderSubscriptions,
|
|
34
34
|
noiseScaleSliderSubscriptions,
|
|
@@ -3,7 +3,6 @@ import { defineMessageUnion } from 'foldkit/message'
|
|
|
3
3
|
|
|
4
4
|
import { Menu, Tabs } from '@foldkit/ui'
|
|
5
5
|
|
|
6
|
-
import { Step } from './domain'
|
|
7
6
|
import {
|
|
8
7
|
Attachments,
|
|
9
8
|
CoverLetter,
|
|
@@ -13,16 +12,6 @@ import {
|
|
|
13
12
|
WorkHistory,
|
|
14
13
|
} from './step'
|
|
15
14
|
|
|
16
|
-
// STEP SUBMODELS
|
|
17
|
-
|
|
18
|
-
// NAVIGATION
|
|
19
|
-
|
|
20
|
-
// PREVIEW
|
|
21
|
-
|
|
22
|
-
// SUBMISSION
|
|
23
|
-
|
|
24
|
-
// UNION
|
|
25
|
-
|
|
26
15
|
export const Message = defineMessageUnion({
|
|
27
16
|
GotPersonalInfoMessage: { message: PersonalInfo.Message },
|
|
28
17
|
GotWorkHistoryMessage: { message: WorkHistory.Message },
|
|
@@ -32,7 +21,6 @@ export const Message = defineMessageUnion({
|
|
|
32
21
|
GotAttachmentsMessage: { message: Attachments.Message },
|
|
33
22
|
GotStepMenuMessage: { message: Menu.Message },
|
|
34
23
|
GotStepTabsMessage: { message: Tabs.Message },
|
|
35
|
-
NavigatedToStep: { step: Step.Step },
|
|
36
24
|
ClickedNext: {},
|
|
37
25
|
ClickedPrevious: {},
|
|
38
26
|
ToggledPreview: {},
|
|
@@ -79,17 +79,6 @@ describe('update', () => {
|
|
|
79
79
|
)
|
|
80
80
|
})
|
|
81
81
|
|
|
82
|
-
test('NavigatedToStep jumps directly to a step', () => {
|
|
83
|
-
story(
|
|
84
|
-
update,
|
|
85
|
-
givenInitial,
|
|
86
|
-
message(Message.NavigatedToStep({ step: 'Skills' })),
|
|
87
|
-
model(model => {
|
|
88
|
-
expect(model.currentStep).toBe('Skills')
|
|
89
|
-
}),
|
|
90
|
-
)
|
|
91
|
-
})
|
|
92
|
-
|
|
93
82
|
test('GotStepTabsMessage selects the matching step', () => {
|
|
94
83
|
story(
|
|
95
84
|
update,
|
|
@@ -139,10 +139,6 @@ export const update = (model: Model, message: Message) =>
|
|
|
139
139
|
|
|
140
140
|
GotStepTabsMessage: ({ message }) => foldStepTabs(model, message),
|
|
141
141
|
|
|
142
|
-
NavigatedToStep: ({ step }) => ({
|
|
143
|
-
model: evo(model, { currentStep: () => step }),
|
|
144
|
-
}),
|
|
145
|
-
|
|
146
142
|
ClickedNext: () => ({ model: evo(model, { currentStep: toNextStep }) }),
|
|
147
143
|
|
|
148
144
|
ClickedPrevious: () => ({
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { Option } from 'effect'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Command,
|
|
4
|
+
Mount,
|
|
5
|
+
click,
|
|
6
|
+
expect,
|
|
7
|
+
given,
|
|
8
|
+
role,
|
|
9
|
+
scene,
|
|
10
|
+
text,
|
|
11
|
+
} from 'foldkit/scene'
|
|
3
12
|
import { evo } from 'foldkit/struct'
|
|
4
13
|
import { describe, test } from 'vitest'
|
|
5
14
|
|
|
@@ -17,6 +26,11 @@ const resolveFocusOption = Command.resolve(
|
|
|
17
26
|
RadioGroup.Message.CompletedFocusOption(),
|
|
18
27
|
)
|
|
19
28
|
|
|
29
|
+
const resolveDialogResources = Mount.resolve(
|
|
30
|
+
Dialog.AcquireResources,
|
|
31
|
+
Dialog.Message.SucceededAcquireResources(),
|
|
32
|
+
)
|
|
33
|
+
|
|
20
34
|
const createTestModel = (): Model => ({
|
|
21
35
|
grid: createEmptyGrid(4),
|
|
22
36
|
undoStack: [],
|
|
@@ -70,6 +84,7 @@ describe('export workflow', () => {
|
|
|
70
84
|
Message.FailedExportPng({ error: 'Canvas 2D context not available' }),
|
|
71
85
|
),
|
|
72
86
|
Command.resolve(Dialog.ShowDialog, Dialog.Message.SucceededShowDialog()),
|
|
87
|
+
resolveDialogResources,
|
|
73
88
|
expect(text('Export Failed')).toExist(),
|
|
74
89
|
expect(text('Canvas 2D context not available')).toExist(),
|
|
75
90
|
expect(role('button', { name: 'Dismiss' })).toExist(),
|
|
@@ -86,12 +101,14 @@ describe('export workflow', () => {
|
|
|
86
101
|
Message.FailedExportPng({ error: 'Canvas 2D context not available' }),
|
|
87
102
|
),
|
|
88
103
|
Command.resolve(Dialog.ShowDialog, Dialog.Message.SucceededShowDialog()),
|
|
104
|
+
resolveDialogResources,
|
|
89
105
|
expect(text('Export Failed')).toExist(),
|
|
90
106
|
click(role('button', { name: 'Dismiss' })),
|
|
91
107
|
Command.resolve(
|
|
92
108
|
Dialog.CloseDialog,
|
|
93
109
|
Dialog.Message.CompletedCloseDialog(),
|
|
94
110
|
),
|
|
111
|
+
Mount.expectEnded(Dialog.AcquireResources),
|
|
95
112
|
expect(text('Export Failed')).toBeAbsent(),
|
|
96
113
|
)
|
|
97
114
|
})
|
|
@@ -200,6 +217,7 @@ describe('grid size change', () => {
|
|
|
200
217
|
click(role('radio', { name: '8' })),
|
|
201
218
|
resolveFocusOption,
|
|
202
219
|
Command.resolve(Dialog.ShowDialog, Dialog.Message.SucceededShowDialog()),
|
|
220
|
+
resolveDialogResources,
|
|
203
221
|
expect(text('Change to 8\u00d78?')).toExist(),
|
|
204
222
|
expect(
|
|
205
223
|
text('This will clear your canvas and reset undo history.'),
|
|
@@ -213,22 +231,23 @@ describe('grid size change', () => {
|
|
|
213
231
|
const modelWithPendingResize: Model = evo(createTestModel(), {
|
|
214
232
|
maybePendingGridSize: () => Option.some(8),
|
|
215
233
|
gridSizeConfirmDialog: () =>
|
|
216
|
-
Dialog.
|
|
234
|
+
Dialog.boot({
|
|
217
235
|
id: 'grid-size-confirm-dialog',
|
|
218
|
-
|
|
219
|
-
}),
|
|
236
|
+
}).model,
|
|
220
237
|
undoStack: () => [createEmptyGrid(4)],
|
|
221
238
|
})
|
|
222
239
|
|
|
223
240
|
scene(
|
|
224
241
|
{ update, view },
|
|
225
242
|
given(modelWithPendingResize),
|
|
243
|
+
resolveDialogResources,
|
|
226
244
|
expect(text('Change to 8\u00d78?')).toExist(),
|
|
227
245
|
click(role('button', { name: 'Clear and Resize' })),
|
|
228
246
|
Command.resolve(
|
|
229
247
|
Dialog.CloseDialog,
|
|
230
248
|
Dialog.Message.CompletedCloseDialog(),
|
|
231
249
|
),
|
|
250
|
+
Mount.expectEnded(Dialog.AcquireResources),
|
|
232
251
|
Command.resolve(SaveCanvas, Message.CompletedSaveCanvas()),
|
|
233
252
|
expect(text('Change to 8\u00d78?')).toBeAbsent(),
|
|
234
253
|
)
|
|
@@ -238,21 +257,22 @@ describe('grid size change', () => {
|
|
|
238
257
|
const modelWithPendingResize: Model = evo(createTestModel(), {
|
|
239
258
|
maybePendingGridSize: () => Option.some(8),
|
|
240
259
|
gridSizeConfirmDialog: () =>
|
|
241
|
-
Dialog.
|
|
260
|
+
Dialog.boot({
|
|
242
261
|
id: 'grid-size-confirm-dialog',
|
|
243
|
-
|
|
244
|
-
}),
|
|
262
|
+
}).model,
|
|
245
263
|
})
|
|
246
264
|
|
|
247
265
|
scene(
|
|
248
266
|
{ update, view },
|
|
249
267
|
given(modelWithPendingResize),
|
|
268
|
+
resolveDialogResources,
|
|
250
269
|
expect(text('Change to 8\u00d78?')).toExist(),
|
|
251
270
|
click(role('button', { name: 'Cancel' })),
|
|
252
271
|
Command.resolve(
|
|
253
272
|
Dialog.CloseDialog,
|
|
254
273
|
Dialog.Message.CompletedCloseDialog(),
|
|
255
274
|
),
|
|
275
|
+
Mount.expectEnded(Dialog.AcquireResources),
|
|
256
276
|
expect(text('Change to 8\u00d78?')).toBeAbsent(),
|
|
257
277
|
)
|
|
258
278
|
})
|
|
@@ -316,10 +316,9 @@ describe('grid size', () => {
|
|
|
316
316
|
const modelWithPending: Model = evo(emptyModel, {
|
|
317
317
|
maybePendingGridSize: () => Option.some(8),
|
|
318
318
|
gridSizeConfirmDialog: () =>
|
|
319
|
-
Dialog.
|
|
319
|
+
Dialog.boot({
|
|
320
320
|
id: 'grid-size-confirm-dialog',
|
|
321
|
-
|
|
322
|
-
}),
|
|
321
|
+
}).model,
|
|
323
322
|
undoStack: () => [createEmptyGrid(4)],
|
|
324
323
|
})
|
|
325
324
|
|
|
@@ -40,7 +40,7 @@ const toToolMessage = (event: KeyboardEvent): Option.Option<Message> => {
|
|
|
40
40
|
|
|
41
41
|
export const subscriptions = Subscription.make<Model, Message>()(entry => ({
|
|
42
42
|
undoRedoKeys: Subscription.persistent(
|
|
43
|
-
Subscription.fromEventFilterMapPreventDefault
|
|
43
|
+
Subscription.fromEventFilterMapPreventDefault({
|
|
44
44
|
target: document,
|
|
45
45
|
type: 'keydown',
|
|
46
46
|
toMessage: toUndoRedoMessage,
|
|
@@ -48,7 +48,7 @@ export const subscriptions = Subscription.make<Model, Message>()(entry => ({
|
|
|
48
48
|
),
|
|
49
49
|
|
|
50
50
|
toolKeys: Subscription.persistent(
|
|
51
|
-
Subscription.fromEventFilterMap
|
|
51
|
+
Subscription.fromEventFilterMap({
|
|
52
52
|
target: document,
|
|
53
53
|
type: 'keydown',
|
|
54
54
|
toMessage: toToolMessage,
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { Runtime } from 'foldkit'
|
|
2
2
|
|
|
3
|
-
import { Message, Model, init, update, view } from './main'
|
|
3
|
+
import { Message, Model, init, subscriptions, update, view } from './main'
|
|
4
4
|
|
|
5
5
|
const application = Runtime.makeApplication({
|
|
6
6
|
Model,
|
|
7
7
|
init,
|
|
8
8
|
update,
|
|
9
9
|
view,
|
|
10
|
+
subscriptions,
|
|
10
11
|
container: document.getElementById('root'),
|
|
11
12
|
routing: {
|
|
12
13
|
onUrlRequest: request => Message.ClickedLink({ request }),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Array, Effect, Match, Option, Schema } from 'effect'
|
|
2
|
-
import { Command, Runtime, Update } from 'foldkit'
|
|
2
|
+
import { Command, Runtime, Subscription, Update } from 'foldkit'
|
|
3
3
|
import { Document, Html, HtmlBuilder } from 'foldkit/html'
|
|
4
4
|
import { defineMessageUnion } from 'foldkit/message'
|
|
5
5
|
import { UrlRequest, load, pushUrl } from 'foldkit/navigation'
|
|
@@ -37,11 +37,15 @@ export type Model = typeof Model.Type
|
|
|
37
37
|
|
|
38
38
|
// MESSAGE
|
|
39
39
|
|
|
40
|
+
const NavigationShortcut = Schema.Literals(['GH', 'GP', 'GF', 'GN'])
|
|
41
|
+
type NavigationShortcut = typeof NavigationShortcut.Type
|
|
42
|
+
|
|
40
43
|
export const Message = defineMessageUnion({
|
|
41
44
|
CompletedNavigateInternal: {},
|
|
42
45
|
CompletedLoadExternal: {},
|
|
43
46
|
ClickedLink: { request: UrlRequest },
|
|
44
47
|
ChangedUrl: { url: Url },
|
|
48
|
+
EnteredNavigationShortcut: { shortcut: NavigationShortcut },
|
|
45
49
|
GotPeopleMessage: { message: People.Message },
|
|
46
50
|
})
|
|
47
51
|
|
|
@@ -59,18 +63,15 @@ export const init: Runtime.RoutingApplicationInit<Model, Message> = (
|
|
|
59
63
|
Match.orElse(() => AppRoute.People({ searchText: Option.none() })),
|
|
60
64
|
)
|
|
61
65
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
Message.GotPeopleMessage({ message: childMessage }),
|
|
67
|
-
),
|
|
68
|
-
}
|
|
66
|
+
return Update.foldChildInit(People.init(initialPeopleRoute), {
|
|
67
|
+
toParentModel: peoplePage => ({ route, peoplePage }),
|
|
68
|
+
toParentMessage: message => Message.GotPeopleMessage({ message }),
|
|
69
|
+
})
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
// COMMAND
|
|
72
73
|
|
|
73
|
-
const NavigateInternal = Command.define('NavigateInternal', {
|
|
74
|
+
export const NavigateInternal = Command.define('NavigateInternal', {
|
|
74
75
|
args: { url: Schema.String },
|
|
75
76
|
messages: [Message.CompletedNavigateInternal],
|
|
76
77
|
execute: ({ url }) =>
|
|
@@ -88,6 +89,15 @@ const LoadExternal = Command.define('LoadExternal', {
|
|
|
88
89
|
|
|
89
90
|
type UpdateReturn = Update.Return<Model, Message>
|
|
90
91
|
|
|
92
|
+
const navigationUrlByShortcut: Readonly<
|
|
93
|
+
Record<NavigationShortcut, () => string>
|
|
94
|
+
> = {
|
|
95
|
+
GH: homeRouter,
|
|
96
|
+
GP: () => peopleRouter({ searchText: Option.none() }),
|
|
97
|
+
GF: filesIndexRouter,
|
|
98
|
+
GN: nestedRouter,
|
|
99
|
+
}
|
|
100
|
+
|
|
91
101
|
const foldPeopleEntry = <Input>(
|
|
92
102
|
update: (peoplePage: People.Model, input: Input) => People.UpdateReturn,
|
|
93
103
|
): Update.Fold<Model, Message, Input> =>
|
|
@@ -138,9 +148,46 @@ export const update = (model: Model, message: Message) =>
|
|
|
138
148
|
return Update.combine(model, [setRoute(nextRoute), ...routeSteps])
|
|
139
149
|
},
|
|
140
150
|
|
|
151
|
+
EnteredNavigationShortcut: ({ shortcut }) => {
|
|
152
|
+
const url = navigationUrlByShortcut[shortcut]()
|
|
153
|
+
|
|
154
|
+
return { model, commands: [NavigateInternal({ url })] }
|
|
155
|
+
},
|
|
156
|
+
|
|
141
157
|
GotPeopleMessage: ({ message }) => foldPeople(model, message),
|
|
142
158
|
})
|
|
143
159
|
|
|
160
|
+
// SUBSCRIPTION
|
|
161
|
+
|
|
162
|
+
export const subscriptions = Subscription.make<Model, Message>()(() => ({
|
|
163
|
+
shortcuts: Subscription.persistent(
|
|
164
|
+
Subscription.keyboardShortcuts<Message>({
|
|
165
|
+
bindings: [
|
|
166
|
+
{
|
|
167
|
+
shortcut: ['G', 'H'],
|
|
168
|
+
toMessage: () =>
|
|
169
|
+
Message.EnteredNavigationShortcut({ shortcut: 'GH' }),
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
shortcut: ['G', 'P'],
|
|
173
|
+
toMessage: () =>
|
|
174
|
+
Message.EnteredNavigationShortcut({ shortcut: 'GP' }),
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
shortcut: ['G', 'F'],
|
|
178
|
+
toMessage: () =>
|
|
179
|
+
Message.EnteredNavigationShortcut({ shortcut: 'GF' }),
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
shortcut: ['G', 'N'],
|
|
183
|
+
toMessage: () =>
|
|
184
|
+
Message.EnteredNavigationShortcut({ shortcut: 'GN' }),
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
}),
|
|
188
|
+
),
|
|
189
|
+
}))
|
|
190
|
+
|
|
144
191
|
// VIEW
|
|
145
192
|
|
|
146
193
|
const navigationView = (
|
|
@@ -3,8 +3,14 @@ import { Command, given, message, model, story } from 'foldkit/story'
|
|
|
3
3
|
import { fromString } from 'foldkit/url'
|
|
4
4
|
import { describe, expect, test } from 'vitest'
|
|
5
5
|
|
|
6
|
-
import { AppRoute, Message, Model, update } from './main'
|
|
6
|
+
import { AppRoute, Message, Model, NavigateInternal, update } from './main'
|
|
7
7
|
import { People } from './page'
|
|
8
|
+
import {
|
|
9
|
+
filesIndexRouter,
|
|
10
|
+
homeRouter,
|
|
11
|
+
nestedRouter,
|
|
12
|
+
peopleRouter,
|
|
13
|
+
} from './route'
|
|
8
14
|
|
|
9
15
|
const peoplePageWith = (searchInput: string) =>
|
|
10
16
|
People.Model.make({
|
|
@@ -47,6 +53,52 @@ const resolveFetch = (searchText: string) =>
|
|
|
47
53
|
)
|
|
48
54
|
|
|
49
55
|
describe('update', () => {
|
|
56
|
+
describe('EnteredNavigationShortcut', () => {
|
|
57
|
+
test('GH produces the Home navigation Command', () => {
|
|
58
|
+
story(
|
|
59
|
+
update,
|
|
60
|
+
given(home),
|
|
61
|
+
message(Message.EnteredNavigationShortcut({ shortcut: 'GH' })),
|
|
62
|
+
Command.expectHas(NavigateInternal({ url: homeRouter() })),
|
|
63
|
+
Command.resolve(NavigateInternal, Message.CompletedNavigateInternal()),
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('GP produces the People navigation Command', () => {
|
|
68
|
+
story(
|
|
69
|
+
update,
|
|
70
|
+
given(home),
|
|
71
|
+
message(Message.EnteredNavigationShortcut({ shortcut: 'GP' })),
|
|
72
|
+
Command.expectHas(
|
|
73
|
+
NavigateInternal({
|
|
74
|
+
url: peopleRouter({ searchText: Option.none() }),
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
77
|
+
Command.resolve(NavigateInternal, Message.CompletedNavigateInternal()),
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('GF produces the Files navigation Command', () => {
|
|
82
|
+
story(
|
|
83
|
+
update,
|
|
84
|
+
given(home),
|
|
85
|
+
message(Message.EnteredNavigationShortcut({ shortcut: 'GF' })),
|
|
86
|
+
Command.expectHas(NavigateInternal({ url: filesIndexRouter() })),
|
|
87
|
+
Command.resolve(NavigateInternal, Message.CompletedNavigateInternal()),
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('GN produces the Nested navigation Command', () => {
|
|
92
|
+
story(
|
|
93
|
+
update,
|
|
94
|
+
given(home),
|
|
95
|
+
message(Message.EnteredNavigationShortcut({ shortcut: 'GN' })),
|
|
96
|
+
Command.expectHas(NavigateInternal({ url: nestedRouter() })),
|
|
97
|
+
Command.resolve(NavigateInternal, Message.CompletedNavigateInternal()),
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
50
102
|
describe('ChangedUrl', () => {
|
|
51
103
|
test('navigating to /people parses to a People route', () => {
|
|
52
104
|
story(
|
|
@@ -61,7 +61,9 @@ export const Message = defineMessageUnion({
|
|
|
61
61
|
|
|
62
62
|
export type Message = typeof Message.Type
|
|
63
63
|
|
|
64
|
-
const slowWarningTarget
|
|
64
|
+
const slowWarningTarget: Subscription.TypedEventTarget<{
|
|
65
|
+
[SLOW_WARNING_EVENT]: CustomEvent<SlowWarningReport>
|
|
66
|
+
}> = new EventTarget()
|
|
65
67
|
|
|
66
68
|
const burnCpu = (durationMs: number): number => {
|
|
67
69
|
const stopAt = performance.now() + durationMs
|
|
@@ -218,10 +220,7 @@ export const init: Runtime.ApplicationInit<Model, Message> = () => ({
|
|
|
218
220
|
|
|
219
221
|
export const subscriptions = Subscription.make<Model, Message>()(entry => ({
|
|
220
222
|
slowWarnings: Subscription.persistent(
|
|
221
|
-
Subscription.fromEventFilterMap
|
|
222
|
-
CustomEvent,
|
|
223
|
-
typeof Message.RecordedSlowWarning.Type
|
|
224
|
-
>({
|
|
223
|
+
Subscription.fromEventFilterMap({
|
|
225
224
|
target: slowWarningTarget,
|
|
226
225
|
type: SLOW_WARNING_EVENT,
|
|
227
226
|
toMessage: event =>
|
|
@@ -252,7 +252,7 @@ export const subscriptions = Subscription.make<Model, Message>()(entry => ({
|
|
|
252
252
|
),
|
|
253
253
|
|
|
254
254
|
keyboard: Subscription.persistent(
|
|
255
|
-
Subscription.fromEventFilterMapPreventDefault
|
|
255
|
+
Subscription.fromEventFilterMapPreventDefault({
|
|
256
256
|
target: document,
|
|
257
257
|
type: 'keydown',
|
|
258
258
|
toMessage: keyboardEvent =>
|
|
@@ -197,17 +197,10 @@ export const init: Runtime.RoutingApplicationInit<Model, Message, Flags> = (
|
|
|
197
197
|
flags: Flags,
|
|
198
198
|
url: Url,
|
|
199
199
|
) => {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
route: urlToAppRoute(url),
|
|
205
|
-
uiModel: uiInit_.model,
|
|
206
|
-
},
|
|
207
|
-
commands: Command.mapMessages(uiInit_.commands, message =>
|
|
208
|
-
Message.GotUiMessage({ message }),
|
|
209
|
-
),
|
|
210
|
-
}
|
|
200
|
+
return Update.foldChildInit(uiInit(flags.today), {
|
|
201
|
+
toParentModel: uiModel => ({ route: urlToAppRoute(url), uiModel }),
|
|
202
|
+
toParentMessage: message => Message.GotUiMessage({ message }),
|
|
203
|
+
})
|
|
211
204
|
}
|
|
212
205
|
|
|
213
206
|
// UPDATE
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Calendar } from 'foldkit'
|
|
2
|
-
import { expect, given, role, scene, text } from 'foldkit/scene'
|
|
2
|
+
import { click, expect, given, role, scene, text } from 'foldkit/scene'
|
|
3
3
|
import { describe, test } from 'vitest'
|
|
4
4
|
|
|
5
5
|
import { AppRoute, type Model, update, view } from './main'
|
|
@@ -85,6 +85,18 @@ describe('view', () => {
|
|
|
85
85
|
)
|
|
86
86
|
})
|
|
87
87
|
|
|
88
|
+
test('the Disclosure collapsed preview expands from its own control', () => {
|
|
89
|
+
scene(
|
|
90
|
+
{ update, view },
|
|
91
|
+
given(modelForRoute(AppRoute.Disclosure())),
|
|
92
|
+
expect(role('heading', { name: 'Basic' })).toExist(),
|
|
93
|
+
expect(role('heading', { name: 'Animated' })).toExist(),
|
|
94
|
+
expect(role('heading', { name: 'Collapsed preview' })).toExist(),
|
|
95
|
+
click(role('button', { name: 'Read more' })),
|
|
96
|
+
expect(role('button', { name: 'Show less' })).toExist(),
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
|
|
88
100
|
test('the NotFound route renders the 404 panel and a Go Home link', () => {
|
|
89
101
|
scene(
|
|
90
102
|
{ update, view },
|
|
@@ -101,10 +101,9 @@ describe('update', () => {
|
|
|
101
101
|
const modelWithOpenMenu: Model = evo(initialModel, {
|
|
102
102
|
uiModel: evo({
|
|
103
103
|
mobileMenuDialog: () =>
|
|
104
|
-
Dialog.
|
|
104
|
+
Dialog.boot({
|
|
105
105
|
id: 'mobile-menu',
|
|
106
|
-
|
|
107
|
-
}),
|
|
106
|
+
}).model,
|
|
108
107
|
}),
|
|
109
108
|
})
|
|
110
109
|
|
|
@@ -86,6 +86,7 @@ export const uiInit = (today: Calendar.CalendarDate): InitReturn => ({
|
|
|
86
86
|
nestedDialogChildDemo: Dialog.init({ id: 'nested-dialog-child-demo' }),
|
|
87
87
|
isDisclosureBasicDemoOpen: false,
|
|
88
88
|
isDisclosureAnimatedDemoOpen: false,
|
|
89
|
+
isDisclosureCollapsedPreviewDemoOpen: false,
|
|
89
90
|
dragAndDropDemo: DragAndDrop.init({ id: 'drag-and-drop-demo' }),
|
|
90
91
|
dragAndDropDemoColumns: [
|
|
91
92
|
{
|
|
@@ -54,6 +54,7 @@ export const Message = defineMessageUnion({
|
|
|
54
54
|
ClickedOpenProjectSettings: {},
|
|
55
55
|
ToggledDisclosureBasicDemo: { isOpen: Schema.Boolean },
|
|
56
56
|
ToggledDisclosureAnimatedDemo: { isOpen: Schema.Boolean },
|
|
57
|
+
ToggledDisclosureCollapsedPreviewDemo: { isOpen: Schema.Boolean },
|
|
57
58
|
GotCalendarBasicDemoMessage: { message: Calendar.Message },
|
|
58
59
|
GotDatePickerBasicDemoMessage: { message: DatePicker.Message },
|
|
59
60
|
GotDragAndDropDemoMessage: { message: DragAndDrop.Message },
|
|
@@ -102,6 +102,7 @@ export const UiModel = Schema.Struct({
|
|
|
102
102
|
nestedDialogChildDemo: Dialog.Model,
|
|
103
103
|
isDisclosureBasicDemoOpen: Schema.Boolean,
|
|
104
104
|
isDisclosureAnimatedDemoOpen: Schema.Boolean,
|
|
105
|
+
isDisclosureCollapsedPreviewDemoOpen: Schema.Boolean,
|
|
105
106
|
dragAndDropDemo: DragAndDrop.Model,
|
|
106
107
|
dragAndDropDemoColumns: Schema.Array(DemoColumn),
|
|
107
108
|
fileDropBasicDemo: FileDrop.Model,
|
|
@@ -46,7 +46,7 @@ const virtualListVariableDemoSubscriptions = Subscription.lift({
|
|
|
46
46
|
UiMessage.GotVirtualListVariableDemoMessage({ message }),
|
|
47
47
|
})
|
|
48
48
|
|
|
49
|
-
export const subscriptions = Subscription.aggregate
|
|
49
|
+
export const subscriptions = Subscription.aggregate(
|
|
50
50
|
dragAndDropSubscriptions,
|
|
51
51
|
sliderRatingSubscriptions,
|
|
52
52
|
sliderVolumeSubscriptions,
|
|
@@ -1009,6 +1009,12 @@ export const uiUpdate = (model: UiModel, message: UiMessage) =>
|
|
|
1009
1009
|
}),
|
|
1010
1010
|
}),
|
|
1011
1011
|
|
|
1012
|
+
ToggledDisclosureCollapsedPreviewDemo: ({ isOpen }) => ({
|
|
1013
|
+
model: evo(model, {
|
|
1014
|
+
isDisclosureCollapsedPreviewDemoOpen: () => isOpen,
|
|
1015
|
+
}),
|
|
1016
|
+
}),
|
|
1017
|
+
|
|
1012
1018
|
GotCalendarBasicDemoMessage: ({ message }) =>
|
|
1013
1019
|
foldCalendarBasicDemo(model, message),
|
|
1014
1020
|
|
|
@@ -10,6 +10,8 @@ import type { UiModel } from '../model'
|
|
|
10
10
|
|
|
11
11
|
const DISCLOSURE_BASIC_DEMO_ID = 'disclosure-basic-demo'
|
|
12
12
|
const DISCLOSURE_ANIMATED_DEMO_ID = 'disclosure-animated-demo'
|
|
13
|
+
const DISCLOSURE_COLLAPSED_PREVIEW_DEMO_ID = 'disclosure-collapsed-preview-demo'
|
|
14
|
+
const COLLAPSED_PREVIEW_HEIGHT = '6rem'
|
|
13
15
|
|
|
14
16
|
const PANEL_TEXT =
|
|
15
17
|
'Foldkit is an Elm-inspired UI framework powered by Effect. It brings the Model-View-Update architecture to TypeScript with Schema-typed state, explicit side effects via commands, and composable headless UI components.'
|
|
@@ -32,6 +34,31 @@ const panelText = (h: HtmlBuilder<UiMessage>): Html => {
|
|
|
32
34
|
return h.p([h.Class('text-gray-800')], [PANEL_TEXT])
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
const collapsedPreviewPanel = (h: HtmlBuilder<UiMessage>): Html =>
|
|
38
|
+
h.div(
|
|
39
|
+
[h.Class('space-y-3 text-base leading-6 text-gray-800')],
|
|
40
|
+
[
|
|
41
|
+
h.p(
|
|
42
|
+
[],
|
|
43
|
+
[
|
|
44
|
+
'Foldkit keeps application state in one Model. Messages record facts, and update decides how each fact changes that Model.',
|
|
45
|
+
],
|
|
46
|
+
),
|
|
47
|
+
h.p(
|
|
48
|
+
[],
|
|
49
|
+
[
|
|
50
|
+
'Commands describe one-time work for the Runtime, so network requests, focus changes, and storage writes stay outside state transitions.',
|
|
51
|
+
],
|
|
52
|
+
),
|
|
53
|
+
h.p(
|
|
54
|
+
[],
|
|
55
|
+
[
|
|
56
|
+
'That separation leaves every transition visible in one place and gives tests the same inputs and outputs the application uses.',
|
|
57
|
+
],
|
|
58
|
+
),
|
|
59
|
+
],
|
|
60
|
+
)
|
|
61
|
+
|
|
35
62
|
const subheading = (text: string, h: HtmlBuilder<UiMessage>): Html => {
|
|
36
63
|
return h.h3(
|
|
37
64
|
[h.Class('text-lg font-semibold text-gray-900 mt-8 mb-4')],
|
|
@@ -136,6 +163,89 @@ export const view = Submodel.defineView<UiModel, UiMessage>(
|
|
|
136
163
|
},
|
|
137
164
|
h,
|
|
138
165
|
),
|
|
166
|
+
|
|
167
|
+
subheading('Collapsed preview', h),
|
|
168
|
+
h.p(
|
|
169
|
+
[h.Class('mb-1.5 text-sm font-medium text-gray-900')],
|
|
170
|
+
['Featured article'],
|
|
171
|
+
),
|
|
172
|
+
Disclosure.view(
|
|
173
|
+
{
|
|
174
|
+
id: DISCLOSURE_COLLAPSED_PREVIEW_DEMO_ID,
|
|
175
|
+
isOpen: model.isDisclosureCollapsedPreviewDemoOpen,
|
|
176
|
+
onToggle: isOpen =>
|
|
177
|
+
UiMessage.ToggledDisclosureCollapsedPreviewDemo({ isOpen }),
|
|
178
|
+
toView: ({ button, panel, animatePanel }) =>
|
|
179
|
+
h.article(
|
|
180
|
+
[
|
|
181
|
+
h.Class(
|
|
182
|
+
'relative overflow-hidden rounded-lg border border-gray-300 bg-white',
|
|
183
|
+
),
|
|
184
|
+
],
|
|
185
|
+
[
|
|
186
|
+
h.h4(
|
|
187
|
+
[h.Class('px-4 py-3 text-base font-normal text-gray-900')],
|
|
188
|
+
['Why the Elm Architecture scales'],
|
|
189
|
+
),
|
|
190
|
+
h.div(
|
|
191
|
+
[h.Class('relative')],
|
|
192
|
+
[
|
|
193
|
+
animatePanel(
|
|
194
|
+
h.div(
|
|
195
|
+
[
|
|
196
|
+
...panel,
|
|
197
|
+
h.Class(
|
|
198
|
+
clsx(
|
|
199
|
+
'border-t border-gray-300 px-4 pt-3',
|
|
200
|
+
model.isDisclosureCollapsedPreviewDemoOpen
|
|
201
|
+
? 'pb-20'
|
|
202
|
+
: 'pb-3',
|
|
203
|
+
),
|
|
204
|
+
),
|
|
205
|
+
],
|
|
206
|
+
[collapsedPreviewPanel(h)],
|
|
207
|
+
),
|
|
208
|
+
{ peek: COLLAPSED_PREVIEW_HEIGHT },
|
|
209
|
+
),
|
|
210
|
+
...(model.isDisclosureCollapsedPreviewDemoOpen
|
|
211
|
+
? []
|
|
212
|
+
: [
|
|
213
|
+
h.div([
|
|
214
|
+
h.AriaHidden(true),
|
|
215
|
+
h.Class(
|
|
216
|
+
'pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white to-transparent',
|
|
217
|
+
),
|
|
218
|
+
]),
|
|
219
|
+
]),
|
|
220
|
+
],
|
|
221
|
+
),
|
|
222
|
+
h.div(
|
|
223
|
+
[
|
|
224
|
+
h.Class(
|
|
225
|
+
'absolute inset-x-0 bottom-2.5 flex justify-center px-4',
|
|
226
|
+
),
|
|
227
|
+
],
|
|
228
|
+
[
|
|
229
|
+
h.button(
|
|
230
|
+
[
|
|
231
|
+
...button,
|
|
232
|
+
h.Class(
|
|
233
|
+
'cursor-pointer whitespace-nowrap rounded-full border border-gray-300 bg-white px-5 py-2 text-sm font-medium text-gray-900 shadow-sm transition hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600 select-none',
|
|
234
|
+
),
|
|
235
|
+
],
|
|
236
|
+
[
|
|
237
|
+
model.isDisclosureCollapsedPreviewDemoOpen
|
|
238
|
+
? 'Show less'
|
|
239
|
+
: 'Read more',
|
|
240
|
+
],
|
|
241
|
+
),
|
|
242
|
+
],
|
|
243
|
+
),
|
|
244
|
+
],
|
|
245
|
+
),
|
|
246
|
+
},
|
|
247
|
+
h,
|
|
248
|
+
),
|
|
139
249
|
],
|
|
140
250
|
)
|
|
141
251
|
},
|
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"channel": "stable",
|
|
4
|
-
"sourceCommit": "
|
|
4
|
+
"sourceCommit": "7a3180b90d4eff772fa22928f31009e7dda9ff8f",
|
|
5
5
|
"packages": {
|
|
6
|
-
"create-foldkit-app": "0.35.
|
|
7
|
-
"@foldkit/devtools": "0.
|
|
8
|
-
"@foldkit/devtools-mcp": "0.
|
|
9
|
-
"foldkit": "0.
|
|
10
|
-
"@foldkit/markdown": "0.
|
|
11
|
-
"@foldkit/oxlint-plugin": "0.
|
|
12
|
-
"@foldkit/ui": "0.
|
|
13
|
-
"@foldkit/vite-plugin": "0.
|
|
6
|
+
"create-foldkit-app": "0.35.1",
|
|
7
|
+
"@foldkit/devtools": "0.161.0",
|
|
8
|
+
"@foldkit/devtools-mcp": "0.21.0",
|
|
9
|
+
"foldkit": "0.161.0",
|
|
10
|
+
"@foldkit/markdown": "0.11.0",
|
|
11
|
+
"@foldkit/oxlint-plugin": "0.14.0",
|
|
12
|
+
"@foldkit/ui": "0.161.0",
|
|
13
|
+
"@foldkit/vite-plugin": "0.23.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@foldkit/devtools": "0.
|
|
17
|
-
"@foldkit/vite-plugin": "0.
|
|
18
|
-
"@foldkit/devtools-mcp": "0.
|
|
19
|
-
"@foldkit/oxlint-plugin": "0.
|
|
16
|
+
"@foldkit/devtools": "0.161.0",
|
|
17
|
+
"@foldkit/vite-plugin": "0.23.0",
|
|
18
|
+
"@foldkit/devtools-mcp": "0.21.0",
|
|
19
|
+
"@foldkit/oxlint-plugin": "0.14.0",
|
|
20
20
|
"@types/node": "^26.4.0",
|
|
21
21
|
"happy-dom": "^20.11.13",
|
|
22
22
|
"oxfmt": "^0.66.0",
|