@gjermundgaraba-designs/pixel-paper 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +29 -22
- package/dist/src/announcer/announcer.d.ts +34 -26
- package/dist/src/announcer/announcer.d.ts.map +1 -1
- package/dist/src/announcer/announcer.js +42 -32
- package/dist/src/announcer/index.d.ts +1 -1
- package/dist/src/announcer/index.d.ts.map +1 -1
- package/dist/src/announcer/index.js +1 -1
- package/dist/src/banner/banner.d.ts +1 -1
- package/dist/src/banner/banner.js +1 -1
- package/dist/src/barChart/barChart.js +1 -1
- package/dist/src/board/board.d.ts +1 -4
- package/dist/src/board/board.d.ts.map +1 -1
- package/dist/src/board/board.js +8 -71
- package/dist/src/button/button.d.ts +1 -1
- package/dist/src/button/button.js +1 -1
- package/dist/src/card/card.d.ts +35 -9
- package/dist/src/card/card.d.ts.map +1 -1
- package/dist/src/card/card.js +46 -8
- package/dist/src/card/index.d.ts +2 -2
- package/dist/src/card/index.d.ts.map +1 -1
- package/dist/src/card/index.js +1 -1
- package/dist/src/commandPalette/commandPalette.d.ts +4 -0
- package/dist/src/commandPalette/commandPalette.d.ts.map +1 -1
- package/dist/src/confirmDialog/confirmDialog.d.ts +18 -26
- package/dist/src/confirmDialog/confirmDialog.d.ts.map +1 -1
- package/dist/src/confirmDialog/confirmDialog.js +21 -19
- package/dist/src/confirmDialog/index.d.ts +1 -1
- package/dist/src/confirmDialog/index.d.ts.map +1 -1
- package/dist/src/confirmDialog/index.js +1 -1
- package/dist/src/contextMenu/contextMenu.d.ts +6 -6
- package/dist/src/dialog/dialog.d.ts +36 -7
- package/dist/src/dialog/dialog.d.ts.map +1 -1
- package/dist/src/dialog/dialog.js +32 -12
- package/dist/src/dialog/index.d.ts +1 -1
- package/dist/src/dialog/index.d.ts.map +1 -1
- package/dist/src/dialog/index.js +1 -1
- package/dist/src/drag/drag.d.ts +40 -0
- package/dist/src/drag/drag.d.ts.map +1 -0
- package/dist/src/drag/drag.js +134 -0
- package/dist/src/emptyState/emptyState.d.ts +6 -4
- package/dist/src/emptyState/emptyState.d.ts.map +1 -1
- package/dist/src/emptyState/emptyState.js +1 -1
- package/dist/src/errorSummary/errorSummary.d.ts +1 -1
- package/dist/src/errorSummary/errorSummary.js +1 -1
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/keyHelp/keyHelp.d.ts +4 -0
- package/dist/src/keyHelp/keyHelp.d.ts.map +1 -1
- package/dist/src/lineChart/lineChart.js +1 -1
- package/dist/src/liveRegion/index.d.ts +3 -0
- package/dist/src/liveRegion/index.d.ts.map +1 -0
- package/dist/src/liveRegion/index.js +1 -0
- package/dist/src/liveRegion/liveRegion.d.ts +42 -0
- package/dist/src/liveRegion/liveRegion.d.ts.map +1 -0
- package/dist/src/liveRegion/liveRegion.js +35 -0
- package/dist/src/loading/loading.d.ts +8 -3
- package/dist/src/loading/loading.d.ts.map +1 -1
- package/dist/src/loading/loading.js +4 -3
- package/dist/src/menu/menu.d.ts +6 -6
- package/dist/src/meter/meter.d.ts +3 -0
- package/dist/src/meter/meter.d.ts.map +1 -1
- package/dist/src/meter/meter.js +6 -3
- package/dist/src/remote/index.d.ts +3 -0
- package/dist/src/remote/index.d.ts.map +1 -0
- package/dist/src/remote/index.js +1 -0
- package/dist/src/remote/remote.d.ts +29 -0
- package/dist/src/remote/remote.d.ts.map +1 -0
- package/dist/src/remote/remote.js +41 -0
- package/dist/src/skipLink/index.d.ts +1 -1
- package/dist/src/skipLink/index.d.ts.map +1 -1
- package/dist/src/skipLink/index.js +1 -1
- package/dist/src/skipLink/skipLink.d.ts +24 -8
- package/dist/src/skipLink/skipLink.d.ts.map +1 -1
- package/dist/src/skipLink/skipLink.js +27 -8
- package/dist/src/sortable/index.d.ts +3 -0
- package/dist/src/sortable/index.d.ts.map +1 -0
- package/dist/src/sortable/index.js +1 -0
- package/dist/src/sortable/sortable.d.ts +1144 -0
- package/dist/src/sortable/sortable.d.ts.map +1 -0
- package/dist/src/sortable/sortable.js +504 -0
- package/dist/src/toast/index.d.ts +1 -1
- package/dist/src/toast/index.d.ts.map +1 -1
- package/dist/src/toast/index.js +1 -1
- package/dist/src/toast/toast.d.ts +32 -20
- package/dist/src/toast/toast.d.ts.map +1 -1
- package/dist/src/toast/toast.js +8 -3
- package/dist/src/topBar/barHeight.d.ts +6 -0
- package/dist/src/topBar/barHeight.d.ts.map +1 -1
- package/dist/src/topBar/barHeight.js +4 -0
- package/dist/src/topBar/index.d.ts +1 -1
- package/dist/src/topBar/index.d.ts.map +1 -1
- package/dist/src/topBar/index.js +1 -1
- package/dist/src/topBar/topBar.d.ts +1 -2
- package/dist/src/topBar/topBar.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/announcer/announcer.ts +48 -32
- package/src/announcer/index.ts +1 -1
- package/src/banner/banner.ts +1 -1
- package/src/barChart/barChart.ts +1 -1
- package/src/board/board.ts +18 -87
- package/src/button/button.ts +1 -1
- package/src/card/card.ts +120 -21
- package/src/card/index.ts +2 -2
- package/src/confirmDialog/confirmDialog.ts +25 -32
- package/src/confirmDialog/index.ts +2 -0
- package/src/dialog/dialog.ts +39 -13
- package/src/dialog/index.ts +3 -1
- package/src/drag/drag.ts +207 -0
- package/src/emptyState/emptyState.ts +7 -5
- package/src/errorSummary/errorSummary.ts +1 -1
- package/src/index.ts +3 -0
- package/src/lineChart/lineChart.ts +1 -1
- package/src/liveRegion/index.ts +2 -0
- package/src/liveRegion/liveRegion.ts +75 -0
- package/src/loading/loading.ts +11 -5
- package/src/meter/meter.ts +17 -3
- package/src/pixel-paper.css +182 -27
- package/src/remote/index.ts +2 -0
- package/src/remote/remote.ts +76 -0
- package/src/skipLink/index.ts +1 -1
- package/src/skipLink/skipLink.ts +38 -9
- package/src/sortable/index.ts +14 -0
- package/src/sortable/sortable.ts +857 -0
- package/src/toast/index.ts +1 -0
- package/src/toast/toast.ts +9 -3
- package/src/tokens.css +13 -11
- package/src/topBar/barHeight.ts +6 -0
- package/src/topBar/index.ts +1 -1
- package/src/topBar/topBar.ts +1 -2
package/src/card/card.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { Array } from "effect";
|
|
|
2
2
|
import type { Html, HtmlBuilder } from "foldkit/html";
|
|
3
3
|
|
|
4
4
|
import type { Attributes } from "../attributes/attributes.ts";
|
|
5
|
+
import * as Button from "../button/button.ts";
|
|
6
|
+
import * as Fields from "../fields/fields.ts";
|
|
5
7
|
import * as IconButton from "../iconButton/iconButton.ts";
|
|
6
8
|
import { slotView } from "../slot/slot.ts";
|
|
7
9
|
|
|
@@ -18,11 +20,6 @@ export type ViewConfig<Message> = Readonly<{
|
|
|
18
20
|
/** Attributes for the close glyph, such as a Dialog's `closeButton` group, which closes it.
|
|
19
21
|
* The glyph prints when they are given. */
|
|
20
22
|
closeAttributes?: Attributes<Message>;
|
|
21
|
-
/** The close glyph is held while work the reader may not walk away from runs: it prints
|
|
22
|
-
* held (`aria-disabled`), stays in the keyboard and drops `closeAttributes`, so a press does
|
|
23
|
-
* nothing. Escape and the veil still send a Dialog's close request, so the parent's update
|
|
24
|
-
* leaves that Message out while it holds the glyph (see `Dialog.isCloseRequest`). */
|
|
25
|
-
isCloseHeld?: boolean;
|
|
26
23
|
/** 440px instead of 680px: confirms, settings, sign-in. */
|
|
27
24
|
isNarrow?: boolean;
|
|
28
25
|
/** The action bar: a 2px ink rule, then these pushed right. Give the default `isDefault`. */
|
|
@@ -32,14 +29,16 @@ export type ViewConfig<Message> = Readonly<{
|
|
|
32
29
|
/** Attributes for the action bar, such as a Dialog's `actionBar` group, which moves focus
|
|
33
30
|
* between the actions with h, l and the side arrows. */
|
|
34
31
|
actionBarAttributes?: Attributes<Message>;
|
|
35
|
-
/** Makes the card a form, named by its title, so it is a form landmark; Enter in a field
|
|
36
|
-
* submits it, whether or not its submit button is held. While the form's work is saving,
|
|
37
|
-
* the parent's update must ignore this Message. */
|
|
38
|
-
onSubmit?: Message;
|
|
39
32
|
}>;
|
|
40
33
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
34
|
+
/** A card's parts; a form card holds its close glyph while it sends (`aria-disabled`, in the
|
|
35
|
+
* keyboard, its close attributes dropped). */
|
|
36
|
+
type Parts<Message> = ViewConfig<Message> & Readonly<{ isCloseHeld?: boolean }>;
|
|
37
|
+
|
|
38
|
+
const childrenView = <Message>(
|
|
39
|
+
config: Parts<Message>,
|
|
40
|
+
h: HtmlBuilder<Message>,
|
|
41
|
+
): ReadonlyArray<Html> => {
|
|
43
42
|
const {
|
|
44
43
|
title,
|
|
45
44
|
titleAttributes = [],
|
|
@@ -47,11 +46,9 @@ export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Messag
|
|
|
47
46
|
body,
|
|
48
47
|
closeAttributes,
|
|
49
48
|
isCloseHeld = false,
|
|
50
|
-
isNarrow = false,
|
|
51
49
|
actions = [],
|
|
52
50
|
lead = [],
|
|
53
51
|
actionBarAttributes = [],
|
|
54
|
-
onSubmit,
|
|
55
52
|
} = config;
|
|
56
53
|
|
|
57
54
|
const titleBar = h.header(
|
|
@@ -80,13 +77,115 @@ export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Messag
|
|
|
80
77
|
onNonEmpty: (bar) => [h.div([...actionBarAttributes, h.Class("card-actions")], bar)],
|
|
81
78
|
});
|
|
82
79
|
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
return [titleBar, h.div([h.Class("card-body")], body), ...actionBar];
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const className = (isNarrow: boolean | undefined): string =>
|
|
84
|
+
isNarrow === true ? "card narrow" : "card";
|
|
85
|
+
|
|
86
|
+
/** The raised surface: paper in a 2px ink box on the 8px print shadow, under a title bar. */
|
|
87
|
+
export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Message>): Html =>
|
|
88
|
+
h.div([h.Class(className(config.isNarrow))], childrenView(config, h));
|
|
89
|
+
|
|
90
|
+
// FORM
|
|
91
|
+
|
|
92
|
+
/** Configuration for rendering a form card with `formView`: a card's, but for its body, its
|
|
93
|
+
* close attributes and its action bar, which the form fills. */
|
|
94
|
+
export type FormViewConfig<Message> = Omit<
|
|
95
|
+
ViewConfig<Message>,
|
|
96
|
+
"body" | "closeAttributes" | "actions" | "lead"
|
|
97
|
+
> &
|
|
98
|
+
Readonly<{
|
|
99
|
+
/** Unique in the document: the submit button's id is `submitId(id)`. */
|
|
100
|
+
id: string;
|
|
101
|
+
/** The form's content, fields and all, in one Fields group that `isBusy` holds. A link in
|
|
102
|
+
* it (an ErrorSummary's) stays live. */
|
|
103
|
+
body: ReadonlyArray<Html | string>;
|
|
104
|
+
/** The submit button's words. It prints primary, the card's default. */
|
|
105
|
+
submit: string;
|
|
106
|
+
/** The cancel button's words, before the submit. It closes through `closeAttributes`. */
|
|
107
|
+
cancel?: string;
|
|
108
|
+
/** Attributes for the close glyph and the cancel button, such as a Dialog's `closeButton`
|
|
109
|
+
* group, which closes it. The glyph prints when they are given. */
|
|
110
|
+
closeAttributes?: Attributes<Message>;
|
|
111
|
+
/** The form's work is running (it sends): the submit button is busy, the close glyph and
|
|
112
|
+
* cancel are held without `closeAttributes`, and the body is natively disabled, so
|
|
113
|
+
* nothing changes under the send and nothing sends twice. In a Dialog, hold its ways out
|
|
114
|
+
* too (`Dialog.setCloseHeld`), and let go of both where the send fails. Enter in a field can submit before the fields are held,
|
|
115
|
+
* so the update still ignores `onSubmit` while busy. */
|
|
116
|
+
isBusy?: boolean;
|
|
117
|
+
/** The form cannot be sent yet (nothing to send): the submit button is held as a Button
|
|
118
|
+
* is, and the fields and cancel stay live. Enter in a field can still submit, so the
|
|
119
|
+
* update ignores `onSubmit` while it cannot be sent. */
|
|
120
|
+
isSubmitDisabled?: boolean;
|
|
121
|
+
/** Sent when the form submits, by its button or by Enter in a field. */
|
|
122
|
+
onSubmit: Message;
|
|
123
|
+
}>;
|
|
124
|
+
|
|
125
|
+
/** The id of a form card's submit button. A send disables the fields, so focus in one falls
|
|
126
|
+
* to the page: the form's update moves it here as it sends, with
|
|
127
|
+
* `Focus.Focus({ id: Card.submitId(id) })`. */
|
|
128
|
+
export const submitId = (id: string): string => `${id}-submit`;
|
|
129
|
+
|
|
130
|
+
/** A card that is a form, named by its title, so a form landmark: its body in one Fields
|
|
131
|
+
* group, then cancel and the primary submit button in the action bar. */
|
|
132
|
+
export const formView = <Message>(
|
|
133
|
+
config: FormViewConfig<Message>,
|
|
134
|
+
h: HtmlBuilder<Message>,
|
|
135
|
+
): Html => {
|
|
136
|
+
const {
|
|
137
|
+
id,
|
|
138
|
+
title,
|
|
139
|
+
submit,
|
|
140
|
+
cancel,
|
|
141
|
+
closeAttributes,
|
|
142
|
+
isBusy = false,
|
|
143
|
+
isSubmitDisabled = false,
|
|
144
|
+
onSubmit,
|
|
145
|
+
} = config;
|
|
146
|
+
|
|
147
|
+
const cancelButton =
|
|
148
|
+
cancel === undefined
|
|
149
|
+
? []
|
|
150
|
+
: [
|
|
151
|
+
Button.view(
|
|
152
|
+
{
|
|
153
|
+
label: cancel,
|
|
154
|
+
isDisabled: isBusy,
|
|
155
|
+
attributes: isBusy ? [] : (closeAttributes ?? []),
|
|
156
|
+
},
|
|
157
|
+
h,
|
|
158
|
+
),
|
|
159
|
+
];
|
|
85
160
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
161
|
+
const submitButton = Button.view(
|
|
162
|
+
{
|
|
163
|
+
label: submit,
|
|
164
|
+
variant: "Primary",
|
|
165
|
+
isDefault: true,
|
|
166
|
+
isBusy,
|
|
167
|
+
isDisabled: isSubmitDisabled,
|
|
168
|
+
type: "submit",
|
|
169
|
+
attributes: [h.Id(submitId(id))],
|
|
170
|
+
},
|
|
171
|
+
h,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
return h.form(
|
|
175
|
+
[
|
|
176
|
+
h.Class(className(config.isNarrow)),
|
|
177
|
+
h.AriaLabel(title),
|
|
178
|
+
h.Novalidate(true),
|
|
179
|
+
h.OnSubmit(onSubmit),
|
|
180
|
+
],
|
|
181
|
+
childrenView(
|
|
182
|
+
{
|
|
183
|
+
...config,
|
|
184
|
+
body: [Fields.view({ body: config.body, isBusy }, h)],
|
|
185
|
+
isCloseHeld: isBusy,
|
|
186
|
+
actions: [...cancelButton, submitButton],
|
|
187
|
+
},
|
|
188
|
+
h,
|
|
189
|
+
),
|
|
190
|
+
);
|
|
92
191
|
};
|
package/src/card/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { view } from "./card.ts";
|
|
2
|
-
export type { ViewConfig } from "./card.ts";
|
|
1
|
+
export { formView, submitId, view } from "./card.ts";
|
|
2
|
+
export type { FormViewConfig, ViewConfig } from "./card.ts";
|
|
@@ -21,13 +21,11 @@ export type Model = typeof Model.Type;
|
|
|
21
21
|
|
|
22
22
|
// MESSAGE
|
|
23
23
|
|
|
24
|
-
/** Union of all Messages a confirm dialog can produce.
|
|
25
|
-
* press on the veil while `isCloseHeld` is set; nothing follows it. */
|
|
24
|
+
/** Union of all Messages a confirm dialog can produce. */
|
|
26
25
|
export const Message = defineMessageUnion({
|
|
27
26
|
GotDialogMessage: { message: Dialog.Message },
|
|
28
27
|
ClickedConfirm: {},
|
|
29
28
|
ClickedCancel: {},
|
|
30
|
-
RequestedCloseWhileHeld: {},
|
|
31
29
|
});
|
|
32
30
|
export type Message = typeof Message.Type;
|
|
33
31
|
|
|
@@ -36,7 +34,7 @@ export type Message = typeof Message.Type;
|
|
|
36
34
|
/** Union of OutMessages a confirm dialog reports. `Confirmed`: the reader pressed the action;
|
|
37
35
|
* the dialog stays open until the parent calls `close`. `Cancelled`: cancel, Escape or a click
|
|
38
36
|
* on the veil; the dialog has closed, and whether that also stops work already running is
|
|
39
|
-
* the parent's to decide. Never reported while `
|
|
37
|
+
* the parent's to decide. Never reported while its ways out are held (`setCloseHeld`). */
|
|
40
38
|
export const OutMessage = defineMessageUnion({
|
|
41
39
|
Confirmed: {},
|
|
42
40
|
Cancelled: {},
|
|
@@ -73,17 +71,25 @@ export const update = (model: Model, message: Message) =>
|
|
|
73
71
|
GotDialogMessage: ({ message: dialogMessage }) => dialogFold.update(model, dialogMessage),
|
|
74
72
|
ClickedConfirm: () => ({ model, outMessage: OutMessage.Confirmed() }),
|
|
75
73
|
ClickedCancel: () =>
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
Dialog.isCloseHeld(model.dialog)
|
|
75
|
+
? { model }
|
|
76
|
+
: pipe(dialogFold.close(model), Update.withOutMessage(OutMessage.Cancelled())),
|
|
78
77
|
});
|
|
79
78
|
|
|
80
79
|
/** Opens the confirm dialog: focus starts on the action, or on cancel when it is `isDanger`. */
|
|
81
80
|
export const open = (model: Model): Update.Return<Model, Message> => dialogFold.open(model);
|
|
82
81
|
|
|
83
|
-
/** Closes the confirm dialog without reporting `Cancelled
|
|
84
|
-
* landed. */
|
|
82
|
+
/** Closes the confirm dialog without reporting `Cancelled`, and lets go of a hold: call it
|
|
83
|
+
* once confirmed work has landed. */
|
|
85
84
|
export const close = (model: Model): Update.Return<Model, Message> => dialogFold.close(model);
|
|
86
85
|
|
|
86
|
+
/** Holds cancel, Escape and the veil, or lets them go: while held the dialog stays open and
|
|
87
|
+
* reports no `Cancelled`, and cancel prints held (`aria-disabled`, in the keyboard). Hold it
|
|
88
|
+
* in the update that starts confirmed work whose failure must land here, and let go in the
|
|
89
|
+
* update where the work fails; `close` lets go once it lands. */
|
|
90
|
+
export const setCloseHeld = (model: Model, isHeld: boolean): Model =>
|
|
91
|
+
modifyFields(model, { dialog: (dialog) => Dialog.setCloseHeld(dialog, isHeld) });
|
|
92
|
+
|
|
87
93
|
// ACCESSORS
|
|
88
94
|
|
|
89
95
|
/** The confirm dialog's id, unique in the document. */
|
|
@@ -92,6 +98,9 @@ export const id = (model: Model): string => Dialog.id(model.dialog);
|
|
|
92
98
|
/** Whether the confirm dialog is open. */
|
|
93
99
|
export const isOpen = (model: Model): boolean => Dialog.isOpen(model.dialog);
|
|
94
100
|
|
|
101
|
+
/** Whether its ways out are held (`setCloseHeld`). */
|
|
102
|
+
export const isCloseHeld = (model: Model): boolean => Dialog.isCloseHeld(model.dialog);
|
|
103
|
+
|
|
95
104
|
// VIEW
|
|
96
105
|
|
|
97
106
|
/** Per-render inputs passed to `view` through `h.submodel`'s `viewInputs`. */
|
|
@@ -114,35 +123,24 @@ export type ViewInputs = Readonly<{
|
|
|
114
123
|
isDanger?: boolean;
|
|
115
124
|
/** The confirmed work is running: the confirming button is held (its word stays, the
|
|
116
125
|
* working cursor prints, a second press does nothing) and keeps focus. Cancel stays live
|
|
117
|
-
* unless `
|
|
126
|
+
* unless the dialog's ways out are held (`setCloseHeld`). */
|
|
118
127
|
isBusy?: boolean;
|
|
119
128
|
/** The action cannot run now (nothing is running; it is blocked): the confirming button is
|
|
120
129
|
* held, `aria-disabled` without the working cursor, and stays in the keyboard, so focus
|
|
121
130
|
* does not fall to the page. Cancel stays live. */
|
|
122
131
|
isDisabled?: boolean;
|
|
123
|
-
/** The reader may not walk away from the work running now (a delete whose failure must
|
|
124
|
-
* land in this dialog): cancel is held as a Button is (`aria-disabled`, in the keyboard,
|
|
125
|
-
* a press does nothing), and Escape and a press on the veil leave the
|
|
126
|
-
* dialog open, so it reports no `Cancelled` while this is set. `close` still closes it.
|
|
127
|
-
* Usually set with `isBusy`. */
|
|
128
|
-
isCloseHeld?: boolean;
|
|
129
132
|
/** The page's live region and toasts while this is the topmost open dialog, `undefined`
|
|
130
133
|
* while it is not (see `Dialog`). */
|
|
131
134
|
toLiveRegionView?: (() => Html) | undefined;
|
|
132
135
|
}>;
|
|
133
136
|
|
|
134
137
|
const cardView = (
|
|
138
|
+
model: Model,
|
|
135
139
|
viewInputs: ViewInputs,
|
|
136
140
|
render: Dialog.RenderInfo,
|
|
137
141
|
h: HtmlBuilder<Message>,
|
|
138
142
|
): Html => {
|
|
139
|
-
const {
|
|
140
|
-
isDanger = false,
|
|
141
|
-
isBusy = false,
|
|
142
|
-
isDisabled = false,
|
|
143
|
-
isCloseHeld = false,
|
|
144
|
-
toDetailView,
|
|
145
|
-
} = viewInputs;
|
|
143
|
+
const { isDanger = false, isBusy = false, isDisabled = false, toDetailView } = viewInputs;
|
|
146
144
|
|
|
147
145
|
const commit = Button.view(
|
|
148
146
|
{
|
|
@@ -160,7 +158,7 @@ const cardView = (
|
|
|
160
158
|
{
|
|
161
159
|
label: "cancel",
|
|
162
160
|
isDefault: isDanger,
|
|
163
|
-
isDisabled: isCloseHeld,
|
|
161
|
+
isDisabled: Dialog.isCloseHeld(model.dialog),
|
|
164
162
|
onClick: Message.ClickedCancel(),
|
|
165
163
|
attributes: isDanger ? render.initialFocus : [],
|
|
166
164
|
},
|
|
@@ -201,8 +199,8 @@ const cardView = (
|
|
|
201
199
|
* double-click through.
|
|
202
200
|
*
|
|
203
201
|
* Work that takes a while stays in the dialog: set `isBusy` while it runs, `close` once it
|
|
204
|
-
* has landed, and on failure keep it open with the error in `toDetailView`.
|
|
205
|
-
* `
|
|
202
|
+
* has landed, and on failure keep it open with the error in `toDetailView`. Hold its ways
|
|
203
|
+
* out with `setCloseHeld` too when the reader must stay to see how it ends.
|
|
206
204
|
*/
|
|
207
205
|
export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h): Html =>
|
|
208
206
|
h.submodel({
|
|
@@ -213,13 +211,8 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h
|
|
|
213
211
|
isAlert: viewInputs.isDanger ?? false,
|
|
214
212
|
hasDescription: true,
|
|
215
213
|
toLiveRegionView: viewInputs.toLiveRegionView,
|
|
216
|
-
toView: (render) => cardView(viewInputs, render, h),
|
|
214
|
+
toView: (render) => cardView(model, viewInputs, render, h),
|
|
217
215
|
},
|
|
218
|
-
|
|
219
|
-
// `RequestedCloseWhileHeld` instead of reaching the Dialog.
|
|
220
|
-
toParentMessage: (message) =>
|
|
221
|
-
viewInputs.isCloseHeld === true && Dialog.isCloseRequest(message)
|
|
222
|
-
? Message.RequestedCloseWhileHeld()
|
|
223
|
-
: Message.GotDialogMessage({ message }),
|
|
216
|
+
toParentMessage: (message) => Message.GotDialogMessage({ message }),
|
|
224
217
|
}),
|
|
225
218
|
);
|
package/src/dialog/dialog.ts
CHANGED
|
@@ -13,9 +13,11 @@ import { foldDialog } from "./fold.ts";
|
|
|
13
13
|
|
|
14
14
|
// MODEL
|
|
15
15
|
|
|
16
|
-
/** Schema for a pixel-paper dialog: the `@foldkit/ui` Dialog it is built on
|
|
16
|
+
/** Schema for a pixel-paper dialog: the `@foldkit/ui` Dialog it is built on, and whether its
|
|
17
|
+
* ways out are held (`setCloseHeld`). */
|
|
17
18
|
export const Model = Schema.Struct({
|
|
18
19
|
dialog: UiDialog.Model,
|
|
20
|
+
isCloseHeld: Schema.Boolean,
|
|
19
21
|
});
|
|
20
22
|
export type Model = typeof Model.Type;
|
|
21
23
|
|
|
@@ -54,6 +56,7 @@ export type InitConfig = Readonly<{ id: string }>;
|
|
|
54
56
|
/** Creates a closed dialog. */
|
|
55
57
|
export const init = (config: InitConfig): Model => ({
|
|
56
58
|
dialog: UiDialog.init({ id: config.id }),
|
|
59
|
+
isCloseHeld: false,
|
|
57
60
|
});
|
|
58
61
|
|
|
59
62
|
// ACCESSORS
|
|
@@ -64,6 +67,9 @@ export const id = (model: Model): string => model.dialog.id;
|
|
|
64
67
|
/** Whether the dialog is open. */
|
|
65
68
|
export const isOpen = (model: Model): boolean => model.dialog.isOpen;
|
|
66
69
|
|
|
70
|
+
/** Whether the dialog's ways out are held (`setCloseHeld`). */
|
|
71
|
+
export const isCloseHeld = (model: Model): boolean => model.isCloseHeld;
|
|
72
|
+
|
|
67
73
|
// UPDATE
|
|
68
74
|
|
|
69
75
|
const toOutMessage = UiDialog.OutMessage.match<OutMessage | undefined>({
|
|
@@ -79,10 +85,17 @@ const dialogFold = foldDialog(UiDialog, {
|
|
|
79
85
|
toParentOutMessage: toOutMessage,
|
|
80
86
|
});
|
|
81
87
|
|
|
88
|
+
// NOTE: @foldkit/ui gap: Dialog has no way to hold a close. Escape, a press on the veil and a
|
|
89
|
+
// control given the `closeButton` group all send `RequestedClose`, so a held dialog reads it
|
|
90
|
+
// by its tag and leaves it out.
|
|
91
|
+
const isHeldClose = (model: Model, message: UiDialog.Message): boolean =>
|
|
92
|
+
model.isCloseHeld && message._tag === "RequestedClose";
|
|
93
|
+
|
|
82
94
|
/** Processes a dialog Message and returns the next Model, Commands and OutMessage. */
|
|
83
95
|
export const update = (model: Model, message: Message) =>
|
|
84
96
|
Message.match<Update.ReturnWithOutMessage<Model, Message, OutMessage>>(message, {
|
|
85
|
-
GotDialogMessage: ({ message: dialogMessage }) =>
|
|
97
|
+
GotDialogMessage: ({ message: dialogMessage }) =>
|
|
98
|
+
isHeldClose(model, dialogMessage) ? { model } : dialogFold.update(model, dialogMessage),
|
|
86
99
|
PressedActionKey: () => ({ model }),
|
|
87
100
|
});
|
|
88
101
|
|
|
@@ -90,17 +103,18 @@ export const update = (model: Model, message: Message) =>
|
|
|
90
103
|
* first control, and returns to where it was when the dialog closes. */
|
|
91
104
|
export const open = (model: Model): Update.Return<Model, Message> => dialogFold.open(model);
|
|
92
105
|
|
|
93
|
-
/** Closes the dialog without reporting `Closed
|
|
94
|
-
export const close = (model: Model): Update.Return<Model, Message> =>
|
|
106
|
+
/** Closes the dialog without reporting `Closed`, and lets go of a hold. */
|
|
107
|
+
export const close = (model: Model): Update.Return<Model, Message> =>
|
|
108
|
+
dialogFold.close(modifyFields(model, { isCloseHeld: () => false }));
|
|
95
109
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
* the
|
|
101
|
-
*
|
|
102
|
-
export const
|
|
103
|
-
|
|
110
|
+
/** Holds the dialog's ways out, or lets them go: while held, Escape, a press on the veil and
|
|
111
|
+
* a control given the `closeButton` group leave it open and report no `Closed`, so the
|
|
112
|
+
* result of work the reader may not walk away from (a save whose failure must land here)
|
|
113
|
+
* lands in it. Hold it in the update that starts the work, and let go in the update where
|
|
114
|
+
* the work fails, or the reader is shut in with the failure; `close` lets go once it lands.
|
|
115
|
+
* Hold the card's own close controls too (`Card.formView`'s `isBusy`). */
|
|
116
|
+
export const setCloseHeld = (model: Model, isHeld: boolean): Model =>
|
|
117
|
+
modifyFields(model, { isCloseHeld: () => isHeld });
|
|
104
118
|
|
|
105
119
|
// ACTION BAR KEYS
|
|
106
120
|
|
|
@@ -157,7 +171,7 @@ export const moveActionFocus = (
|
|
|
157
171
|
|
|
158
172
|
/** The action bar's keys: h or ←, and l or →, move focus between its actions (see
|
|
159
173
|
* `moveActionFocus`). Attached by the `actionBar` group. In a Scene, resolve it with
|
|
160
|
-
* `
|
|
174
|
+
* `sceneMounts`. */
|
|
161
175
|
export const BindActionKeys = Mount.defineStream("BindActionKeys", {
|
|
162
176
|
messages: [Message.PressedActionKey],
|
|
163
177
|
execute: ({ element }) =>
|
|
@@ -171,6 +185,18 @@ export const BindActionKeys = Mount.defineStream("BindActionKeys", {
|
|
|
171
185
|
}),
|
|
172
186
|
});
|
|
173
187
|
|
|
188
|
+
/** What a parent's Scene resolves an open dialog's Mounts with (`@foldkit/ui`'s resources,
|
|
189
|
+
* then the action bar's keys), each with a Message that changes nothing:
|
|
190
|
+
* `Scene.Mount.resolveAll(...Dialog.sceneMounts)`. It serves every dialog built on this one
|
|
191
|
+
* (ConfirmDialog, KeyHelp, CommandPalette), since a Scene lifts the Messages for it. */
|
|
192
|
+
export const sceneMounts = [
|
|
193
|
+
// NOTE: @foldkit/ui gap: its Dialog ships no Scene resolution for its resources Mount, so
|
|
194
|
+
// this test helper builds the child Message the Mount would report
|
|
195
|
+
// oxlint-disable-next-line foldkit/no-child-message-construction-in-root
|
|
196
|
+
[UiDialog.AcquireResources, UiDialog.Message.SucceededAcquireResources()],
|
|
197
|
+
[BindActionKeys, Message.PressedActionKey({ direction: "Next" })],
|
|
198
|
+
] as const;
|
|
199
|
+
|
|
174
200
|
// VIEW
|
|
175
201
|
|
|
176
202
|
/** Attribute groups a dialog hands its content. Spread each onto the element it names; a Card
|
package/src/dialog/index.ts
CHANGED
package/src/drag/drag.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Shared by Board and Sortable, the two users of @foldkit/ui's DragAndDrop: the phase a drag
|
|
2
|
+
// is in, the DragAndDrop Messages both build, what a press lifts, Escape taken before anything
|
|
3
|
+
// else, the lifted copy's measure, and the closest-centre drop target a sortable resolves for
|
|
4
|
+
// a pointer.
|
|
5
|
+
|
|
6
|
+
import { DragAndDrop as UiDragAndDrop } from "@foldkit/ui";
|
|
7
|
+
import { Array, Effect, Option, Order, Stream, pipe } from "effect";
|
|
8
|
+
import * as Dom from "foldkit/dom";
|
|
9
|
+
|
|
10
|
+
// PHASE
|
|
11
|
+
|
|
12
|
+
/** Where a drag is: nothing pressed, pressed but not yet moved, carried by a pointer, or
|
|
13
|
+
* carried by the keyboard. */
|
|
14
|
+
type Phase = "Idle" | "Pressed" | "PointerDragging" | "KeyboardDragging";
|
|
15
|
+
|
|
16
|
+
// NOTE: @foldkit/ui gap: DragAndDrop has no accessor for which input holds an item.
|
|
17
|
+
// `ghostStyle` is Some only while a pointer drags, so it tells a pointer drag from a keyboard
|
|
18
|
+
// one.
|
|
19
|
+
export const phaseOf = (dragAndDrop: UiDragAndDrop.Model): Phase => {
|
|
20
|
+
if (Option.isNone(UiDragAndDrop.maybeDraggedItemId(dragAndDrop))) {
|
|
21
|
+
return "Idle";
|
|
22
|
+
} else if (!UiDragAndDrop.isDragging(dragAndDrop)) {
|
|
23
|
+
return "Pressed";
|
|
24
|
+
} else if (Option.isSome(UiDragAndDrop.ghostStyle(dragAndDrop))) {
|
|
25
|
+
return "PointerDragging";
|
|
26
|
+
} else {
|
|
27
|
+
return "KeyboardDragging";
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Whether an item is carried, by a pointer or the keyboard. */
|
|
32
|
+
export const isCarrying = (phase: Phase): boolean =>
|
|
33
|
+
phase === "PointerDragging" || phase === "KeyboardDragging";
|
|
34
|
+
|
|
35
|
+
// CHILD MESSAGES
|
|
36
|
+
|
|
37
|
+
// NOTE: @foldkit/ui gap: DragAndDrop exposes no update capability for a press, a keyboard
|
|
38
|
+
// pick-up, a pointer's move and release, or a cancel, and its `draggable` bundle
|
|
39
|
+
// (`role="option"`, `tabindex`) cannot sit on a grip's button. So Board and Sortable build
|
|
40
|
+
// these child Messages themselves.
|
|
41
|
+
export const pressedDraggable = (
|
|
42
|
+
fields: Parameters<typeof UiDragAndDrop.Message.PressedDraggable>[0],
|
|
43
|
+
): UiDragAndDrop.Message =>
|
|
44
|
+
// oxlint-disable-next-line foldkit/no-child-message-construction-in-root
|
|
45
|
+
UiDragAndDrop.Message.PressedDraggable(fields);
|
|
46
|
+
|
|
47
|
+
export const activatedKeyboardDrag = (
|
|
48
|
+
fields: Parameters<typeof UiDragAndDrop.Message.ActivatedKeyboardDrag>[0],
|
|
49
|
+
): UiDragAndDrop.Message =>
|
|
50
|
+
// oxlint-disable-next-line foldkit/no-child-message-construction-in-root
|
|
51
|
+
UiDragAndDrop.Message.ActivatedKeyboardDrag(fields);
|
|
52
|
+
|
|
53
|
+
export const movedPointer = (
|
|
54
|
+
fields: Parameters<typeof UiDragAndDrop.Message.MovedPointer>[0],
|
|
55
|
+
): UiDragAndDrop.Message =>
|
|
56
|
+
// oxlint-disable-next-line foldkit/no-child-message-construction-in-root
|
|
57
|
+
UiDragAndDrop.Message.MovedPointer(fields);
|
|
58
|
+
|
|
59
|
+
export const releasedPointer = (): UiDragAndDrop.Message =>
|
|
60
|
+
// oxlint-disable-next-line foldkit/no-child-message-construction-in-root
|
|
61
|
+
UiDragAndDrop.Message.ReleasedPointer();
|
|
62
|
+
|
|
63
|
+
export const cancelledDrag = (): UiDragAndDrop.Message =>
|
|
64
|
+
// oxlint-disable-next-line foldkit/no-child-message-construction-in-root
|
|
65
|
+
UiDragAndDrop.Message.CancelledDrag();
|
|
66
|
+
|
|
67
|
+
// PRESS
|
|
68
|
+
|
|
69
|
+
/** The main mouse button, the only one that lifts. */
|
|
70
|
+
export const LEFT_BUTTON = 0;
|
|
71
|
+
|
|
72
|
+
/** What a press inside an item may land on without lifting it: its controls, but for the
|
|
73
|
+
* buttons `liftingButtons` (a selector list) names, and anything marked `data-no-lift`. */
|
|
74
|
+
export const innerControls = (liftingButtons: string): string =>
|
|
75
|
+
[
|
|
76
|
+
"input",
|
|
77
|
+
"textarea",
|
|
78
|
+
"select",
|
|
79
|
+
"a[href]",
|
|
80
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
81
|
+
`button:not(${liftingButtons})`,
|
|
82
|
+
"[data-no-lift]",
|
|
83
|
+
].join(", ");
|
|
84
|
+
|
|
85
|
+
// ESCAPE
|
|
86
|
+
|
|
87
|
+
// NOTE: @foldkit/ui gap: DragAndDrop's own Escape cancels the move and lets the key travel
|
|
88
|
+
// on, to a dialog around the list. Board and Sortable take the key first and stop it.
|
|
89
|
+
/** Takes a plain Escape for `message` and stops it there, so a dialog around the list or the
|
|
90
|
+
* page's own key bindings never see it. */
|
|
91
|
+
export const takeEscape = <Message>(
|
|
92
|
+
event: KeyboardEvent,
|
|
93
|
+
message: Message,
|
|
94
|
+
): Option.Option<Message> => {
|
|
95
|
+
if (event.key !== "Escape" || event.isComposing) {
|
|
96
|
+
return Option.none();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
event.stopPropagation();
|
|
101
|
+
return Option.some(message);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Escape on the window, in the capture phase, as `message`. */
|
|
105
|
+
export const escapeKeys = <Message>(message: Message): Stream.Stream<Message> =>
|
|
106
|
+
Dom.streamFromEventFilterMap({
|
|
107
|
+
target: () => window,
|
|
108
|
+
type: "keydown",
|
|
109
|
+
filterMapEvent: (event) => takeEscape(event, message),
|
|
110
|
+
options: { capture: true },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// LIFTED COPY
|
|
114
|
+
|
|
115
|
+
/** Sizes the copy that follows the pointer to the element `sourceSelector` finds, and keeps the
|
|
116
|
+
* pointer where it pressed that element. */
|
|
117
|
+
export const measureLift = (
|
|
118
|
+
element: Element,
|
|
119
|
+
sourceSelector: string,
|
|
120
|
+
pressX: number,
|
|
121
|
+
pressY: number,
|
|
122
|
+
): void => {
|
|
123
|
+
const maybeSource = Option.fromNullishOr(document.querySelector(sourceSelector));
|
|
124
|
+
if (Option.isSome(maybeSource) && element instanceof HTMLElement) {
|
|
125
|
+
const rect = maybeSource.value.getBoundingClientRect();
|
|
126
|
+
element.style.setProperty("--lift-width", `${rect.width}px`);
|
|
127
|
+
element.style.setProperty("--lift-x", `${rect.left - pressX}px`);
|
|
128
|
+
element.style.setProperty("--lift-y", `${rect.top - pressY}px`);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// POINTER TARGET
|
|
133
|
+
|
|
134
|
+
const ROW_TOLERANCE = 1;
|
|
135
|
+
|
|
136
|
+
type Box = Readonly<{ top: number; centreX: number; centreY: number }>;
|
|
137
|
+
|
|
138
|
+
const boxOf = (element: Element): Box => {
|
|
139
|
+
const rect = element.getBoundingClientRect();
|
|
140
|
+
return {
|
|
141
|
+
top: rect.top,
|
|
142
|
+
centreX: rect.left + rect.width / 2,
|
|
143
|
+
centreY: rect.top + rect.height / 2,
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const isOnSameRow = (box: Box, other: Box): boolean =>
|
|
148
|
+
Math.abs(other.top - box.top) < ROW_TOLERANCE;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Where a pointer at (`clientX`, `clientY`) would put the held item among the others in
|
|
152
|
+
* `container` (its `[data-sortable-id]` items, the held one, `heldId`, left out): before or
|
|
153
|
+
* after the item whose centre is closest. Where items sit side by side (two share a top, a
|
|
154
|
+
* wrapping grid, the held one counted) the pointer's side of that centre is read across, and
|
|
155
|
+
* in a column of rows it is read down, so one reading serves a list and a grid.
|
|
156
|
+
*/
|
|
157
|
+
export const insertionIndex = (
|
|
158
|
+
container: Element,
|
|
159
|
+
heldId: string,
|
|
160
|
+
clientX: number,
|
|
161
|
+
clientY: number,
|
|
162
|
+
): number => {
|
|
163
|
+
const items = Array.map(
|
|
164
|
+
Array.fromIterable(container.querySelectorAll<HTMLElement>("[data-sortable-id]")),
|
|
165
|
+
(item) => ({ id: item.dataset["sortableId"], box: boxOf(item) }),
|
|
166
|
+
);
|
|
167
|
+
// read with the held item in its place: two tiles side by side are a row even while one of
|
|
168
|
+
// them is held
|
|
169
|
+
const isTiled = Array.some(items, ({ box }, index) =>
|
|
170
|
+
Option.exists(Array.get(items, index + 1), (next) => isOnSameRow(box, next.box)),
|
|
171
|
+
);
|
|
172
|
+
const boxes = pipe(
|
|
173
|
+
Array.filter(items, (item) => item.id !== heldId),
|
|
174
|
+
Array.map(({ box }) => box),
|
|
175
|
+
);
|
|
176
|
+
const distance = (box: Box): number => Math.hypot(box.centreX - clientX, box.centreY - clientY);
|
|
177
|
+
|
|
178
|
+
const byDistance = Order.mapInput(Order.Number, ({ box }: Readonly<{ box: Box }>) =>
|
|
179
|
+
distance(box),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
return Array.match(
|
|
183
|
+
Array.map(boxes, (box, index) => ({ box, index })),
|
|
184
|
+
{
|
|
185
|
+
onEmpty: () => 0,
|
|
186
|
+
onNonEmpty: (candidates) => {
|
|
187
|
+
const { box, index } = Array.min(candidates, byDistance);
|
|
188
|
+
const isAfter = isTiled ? clientX > box.centreX : clientY > box.centreY;
|
|
189
|
+
return isAfter ? index + 1 : index;
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// DRAG STYLES
|
|
196
|
+
|
|
197
|
+
const DRAGGING_ATTRIBUTE = "data-pixel-paper-dragging";
|
|
198
|
+
|
|
199
|
+
/** While a pointer presses or carries an item, the page is marked
|
|
200
|
+
* (`data-pixel-paper-dragging` on the root), so the stylesheet prints the grabbing cursor and
|
|
201
|
+
* holds text selection, with no style element of its own. */
|
|
202
|
+
export const markDragging: Stream.Stream<never> = Stream.callback<never>(() =>
|
|
203
|
+
Effect.acquireRelease(
|
|
204
|
+
Effect.sync(() => document.documentElement.setAttribute(DRAGGING_ATTRIBUTE, "")),
|
|
205
|
+
() => Effect.sync(() => document.documentElement.removeAttribute(DRAGGING_ATTRIBUTE)),
|
|
206
|
+
).pipe(Effect.flatMap(() => Effect.never)),
|
|
207
|
+
);
|