@skillcdn/console 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/api.d.ts +4 -4
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +4 -4
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts +5 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +290 -46
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/credentials.d.ts +5 -0
- package/dist/cli/credentials.d.ts.map +1 -1
- package/dist/cli/credentials.js +2 -0
- package/dist/cli/credentials.js.map +1 -1
- package/dist/cli/directory.d.ts +7 -0
- package/dist/cli/directory.d.ts.map +1 -1
- package/dist/cli/directory.js +7 -0
- package/dist/cli/directory.js.map +1 -1
- package/dist/cli/format.d.ts +10 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +76 -20
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +42 -23
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +1 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/client.d.ts +39 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +24 -3
- package/dist/client.js.map +1 -1
- package/dist/compat.d.ts +28 -6
- package/dist/compat.d.ts.map +1 -1
- package/dist/compat.js +55 -14
- package/dist/compat.js.map +1 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +6 -4
- package/dist/components/board.js.map +1 -1
- package/dist/components/calendar.d.ts +18 -8
- package/dist/components/calendar.d.ts.map +1 -1
- package/dist/components/calendar.js +39 -27
- package/dist/components/calendar.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +2 -0
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/pack-connect.d.ts +23 -0
- package/dist/components/pack-connect.d.ts.map +1 -0
- package/dist/components/pack-connect.js +86 -0
- package/dist/components/pack-connect.js.map +1 -0
- package/dist/components/pack-token.d.ts +27 -0
- package/dist/components/pack-token.d.ts.map +1 -0
- package/dist/components/pack-token.js +11 -0
- package/dist/components/pack-token.js.map +1 -0
- package/dist/components/picker.d.ts +39 -0
- package/dist/components/picker.d.ts.map +1 -0
- package/dist/components/picker.js +155 -0
- package/dist/components/picker.js.map +1 -0
- package/dist/components/projects.d.ts +13 -0
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +37 -5
- package/dist/components/projects.js.map +1 -1
- package/dist/components/task-form.d.ts +18 -4
- package/dist/components/task-form.d.ts.map +1 -1
- package/dist/components/task-form.js +56 -13
- package/dist/components/task-form.js.map +1 -1
- package/dist/components/task-list.js +2 -2
- package/dist/components/task-list.js.map +1 -1
- package/dist/components/task-view.d.ts +7 -1
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +6 -5
- package/dist/components/task-view.js.map +1 -1
- package/dist/components/ui.d.ts +7 -0
- package/dist/components/ui.d.ts.map +1 -1
- package/dist/components/ui.js +11 -0
- package/dist/components/ui.js.map +1 -1
- package/dist/components/work.d.ts +3 -1
- package/dist/components/work.d.ts.map +1 -1
- package/dist/components/work.js +6 -4
- package/dist/components/work.js.map +1 -1
- package/dist/components/workspace.d.ts +46 -0
- package/dist/components/workspace.d.ts.map +1 -0
- package/dist/components/workspace.js +124 -0
- package/dist/components/workspace.js.map +1 -0
- package/dist/console.css +319 -11
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +83 -24
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +26 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +59 -1
- package/dist/data.js.map +1 -1
- package/dist/dates.d.ts +12 -2
- package/dist/dates.d.ts.map +1 -1
- package/dist/dates.js +20 -2
- package/dist/dates.js.map +1 -1
- package/dist/i18n/en.d.ts +64 -3
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +81 -16
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +77 -16
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +2 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +2 -0
- package/dist/limits.js.map +1 -1
- package/dist/router.d.ts +4 -4
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +11 -2
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +4 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +4 -0
- package/dist/routes.js.map +1 -1
- package/dist/schemas.d.ts +194 -13
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +121 -21
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +21 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +18 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +25 -20
- package/docs/specs/rest.md +42 -19
- package/package.json +2 -2
- package/skills/building-a-console/SKILL.md +6 -6
- package/skills/building-a-console/references/the-look-from-a-website.md +39 -0
- package/skills/deploying-a-console/SKILL.md +9 -8
- package/skills/deploying-a-console/references/resources.md +1 -1
- package/skills/working-the-board/SKILL.md +5 -5
- package/skills/working-the-board/references/setting-up.md +1 -1
- package/skills/working-the-board/references/terms.md +3 -1
- package/skills/working-the-board/references/the-sentence.md +1 -1
- package/src/api.ts +21 -0
- package/src/cli/cli.ts +384 -51
- package/src/cli/credentials.ts +7 -0
- package/src/cli/directory.ts +9 -0
- package/src/cli/format.ts +83 -20
- package/src/cli/help.ts +50 -23
- package/src/cli/main.ts +1 -0
- package/src/client.ts +107 -10
- package/src/compat.ts +63 -14
- package/src/components/board.tsx +10 -4
- package/src/components/calendar.tsx +76 -34
- package/src/components/event-feed.tsx +2 -0
- package/src/components/pack-connect.tsx +179 -0
- package/src/components/pack-token.tsx +74 -0
- package/src/components/picker.tsx +297 -0
- package/src/components/projects.tsx +108 -9
- package/src/components/task-form.tsx +149 -63
- package/src/components/task-list.tsx +2 -2
- package/src/components/task-view.tsx +23 -9
- package/src/components/ui.tsx +25 -0
- package/src/components/work.tsx +10 -5
- package/src/components/workspace.tsx +298 -0
- package/src/console.tsx +202 -32
- package/src/data.ts +101 -1
- package/src/dates.ts +23 -2
- package/src/i18n/en.ts +92 -16
- package/src/i18n/ko.ts +84 -16
- package/src/index.ts +32 -1
- package/src/limits.ts +2 -0
- package/src/router.ts +17 -5
- package/src/routes.ts +4 -0
- package/src/schemas.ts +140 -19
- package/src/styles/console.css +319 -11
- package/src/vocabulary.ts +28 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { PackConnectEnding, RestPackConnect, RestPackConnectStarted } from "../api.js";
|
|
3
|
+
import { useWords } from "../i18n/index.js";
|
|
4
|
+
import { Button, Time } from "./ui.js";
|
|
5
|
+
|
|
6
|
+
// A private pack connected with a code a person approves (ADR-0036): an owner, or for the
|
|
7
|
+
// workspace's token an administrator, asks, the console asks the deployment and shows what it
|
|
8
|
+
// answered, the code and the page, and the person approves there; the page asks the console
|
|
9
|
+
// again at the interval the deployment named until the token is kept or the request ended.
|
|
10
|
+
// Takes its data through the callbacks and keeps nothing itself.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* How a connection stands, as the component needs it: what the API answers for a project's
|
|
14
|
+
* token and for the workspace's both say this much, with what was connected beside it.
|
|
15
|
+
*/
|
|
16
|
+
export type PackConnectState =
|
|
17
|
+
| Extract<RestPackConnect, { status: "none" | "pending" | "ended" }>
|
|
18
|
+
| { readonly status: "connected" };
|
|
19
|
+
|
|
20
|
+
export interface PackConnectProps {
|
|
21
|
+
/** Asks the deployment to connect; answers what the person is shown. */
|
|
22
|
+
readonly onStart: () => Promise<RestPackConnectStarted>;
|
|
23
|
+
/** How the connection stands; the token is kept when the person approved. */
|
|
24
|
+
readonly poll: (signal?: AbortSignal) => Promise<PackConnectState>;
|
|
25
|
+
/** Gives the connection up. */
|
|
26
|
+
readonly onCancel: () => Promise<void>;
|
|
27
|
+
/** Called once the token is kept. */
|
|
28
|
+
readonly onConnected?: (() => void) | undefined;
|
|
29
|
+
readonly busy?: boolean | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Shown =
|
|
33
|
+
| { readonly kind: "idle" }
|
|
34
|
+
| {
|
|
35
|
+
readonly kind: "waiting";
|
|
36
|
+
readonly code: string;
|
|
37
|
+
readonly url: string;
|
|
38
|
+
readonly expiresAt: string;
|
|
39
|
+
readonly intervalSeconds: number;
|
|
40
|
+
}
|
|
41
|
+
| { readonly kind: "ended"; readonly reason: PackConnectEnding }
|
|
42
|
+
| { readonly kind: "connected" };
|
|
43
|
+
|
|
44
|
+
/** The least often the console is asked again, whatever the deployment says. */
|
|
45
|
+
const MIN_INTERVAL_SECONDS = 2;
|
|
46
|
+
|
|
47
|
+
export function PackConnect(props: PackConnectProps) {
|
|
48
|
+
const words = useWords();
|
|
49
|
+
const pack = words.projects.packToken;
|
|
50
|
+
const { poll, onConnected } = props;
|
|
51
|
+
const [shown, setShown] = useState<Shown>({ kind: "idle" });
|
|
52
|
+
const [problem, setProblem] = useState<string | undefined>(undefined);
|
|
53
|
+
|
|
54
|
+
// A request begun before the page was opened is picked up where it stands.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
poll(controller.signal)
|
|
58
|
+
.then((state) => {
|
|
59
|
+
if (!controller.signal.aborted && state.status === "pending") {
|
|
60
|
+
setShown({ kind: "waiting", ...state });
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
.catch(() => undefined);
|
|
64
|
+
return () => controller.abort();
|
|
65
|
+
}, [poll]);
|
|
66
|
+
|
|
67
|
+
// While waiting, the console is asked again at the interval the deployment named; a new
|
|
68
|
+
// answer, pending still, is a new state, which schedules the next asking.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (shown.kind !== "waiting") {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
poll(controller.signal)
|
|
76
|
+
.then((state) => {
|
|
77
|
+
if (controller.signal.aborted) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
setProblem(undefined);
|
|
81
|
+
if (state.status === "pending") {
|
|
82
|
+
setShown({ kind: "waiting", ...state });
|
|
83
|
+
} else if (state.status === "connected") {
|
|
84
|
+
setShown({ kind: "connected" });
|
|
85
|
+
onConnected?.();
|
|
86
|
+
} else if (state.status === "ended") {
|
|
87
|
+
setShown({ kind: "ended", reason: state.reason });
|
|
88
|
+
} else {
|
|
89
|
+
setShown({ kind: "idle" });
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
.catch((failure: unknown) => {
|
|
93
|
+
if (controller.signal.aborted) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// The console could not be asked this time; it is asked again.
|
|
97
|
+
setProblem(failure instanceof Error ? failure.message : String(failure));
|
|
98
|
+
setShown((current) => (current.kind === "waiting" ? { ...current } : current));
|
|
99
|
+
});
|
|
100
|
+
}, Math.max(shown.intervalSeconds, MIN_INTERVAL_SECONDS) * 1000);
|
|
101
|
+
return () => {
|
|
102
|
+
controller.abort();
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
};
|
|
105
|
+
}, [shown, poll, onConnected]);
|
|
106
|
+
|
|
107
|
+
const start = () => {
|
|
108
|
+
setProblem(undefined);
|
|
109
|
+
props
|
|
110
|
+
.onStart()
|
|
111
|
+
.then((started) => setShown({ kind: "waiting", ...started }))
|
|
112
|
+
.catch((failure: unknown) => {
|
|
113
|
+
setProblem(failure instanceof Error ? failure.message : String(failure));
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
const cancel = () => {
|
|
117
|
+
props
|
|
118
|
+
.onCancel()
|
|
119
|
+
.then(() => setShown({ kind: "idle" }))
|
|
120
|
+
.catch((failure: unknown) => {
|
|
121
|
+
setProblem(failure instanceof Error ? failure.message : String(failure));
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className="sc-pack-connect" aria-live="polite">
|
|
127
|
+
{shown.kind === "waiting" ? (
|
|
128
|
+
<>
|
|
129
|
+
<p className="sc-pack-connect-code">
|
|
130
|
+
<span className="sc-field-label">{pack.connectCode}</span>
|
|
131
|
+
<code className="sc-pack-code">{shown.code}</code>
|
|
132
|
+
</p>
|
|
133
|
+
<div className="sc-form-actions">
|
|
134
|
+
<a
|
|
135
|
+
className="sc-button sc-button-primary"
|
|
136
|
+
href={shown.url}
|
|
137
|
+
target="_blank"
|
|
138
|
+
rel="noopener noreferrer"
|
|
139
|
+
>
|
|
140
|
+
{pack.connectOpen}
|
|
141
|
+
</a>
|
|
142
|
+
<Button variant="ghost" disabled={props.busy === true} onClick={cancel}>
|
|
143
|
+
{pack.connectCancel}
|
|
144
|
+
</Button>
|
|
145
|
+
</div>
|
|
146
|
+
<p className="sc-pack-connect-waiting">
|
|
147
|
+
<span className="sc-spinner" aria-hidden="true">
|
|
148
|
+
<span className="sc-spinner-ring" />
|
|
149
|
+
</span>
|
|
150
|
+
<span>
|
|
151
|
+
{pack.connectWaiting} {pack.connectExpires} <Time iso={shown.expiresAt} />
|
|
152
|
+
</span>
|
|
153
|
+
</p>
|
|
154
|
+
</>
|
|
155
|
+
) : shown.kind === "connected" ? (
|
|
156
|
+
<p>{pack.connectDone}</p>
|
|
157
|
+
) : (
|
|
158
|
+
<>
|
|
159
|
+
<p className="sc-field-hint">{pack.connectHint}</p>
|
|
160
|
+
{shown.kind === "ended" && (
|
|
161
|
+
<p className="sc-form-error" role="alert">
|
|
162
|
+
{pack.connectEnded[shown.reason]}
|
|
163
|
+
</p>
|
|
164
|
+
)}
|
|
165
|
+
<div className="sc-form-actions">
|
|
166
|
+
<Button variant="primary" disabled={props.busy === true} onClick={start}>
|
|
167
|
+
{pack.connect}
|
|
168
|
+
</Button>
|
|
169
|
+
</div>
|
|
170
|
+
</>
|
|
171
|
+
)}
|
|
172
|
+
{problem !== undefined && (
|
|
173
|
+
<p className="sc-form-error" role="alert">
|
|
174
|
+
{problem}
|
|
175
|
+
</p>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { RestPackConnectStarted } from "../api.js";
|
|
2
|
+
import { useWords } from "../i18n/index.js";
|
|
3
|
+
import { PackConnect, type PackConnectState } from "./pack-connect.js";
|
|
4
|
+
import { PackTokenForm } from "./projects.js";
|
|
5
|
+
import { Button } from "./ui.js";
|
|
6
|
+
|
|
7
|
+
// The SkillCDN token a private pack is read with (ADR-0028, ADR-0036), as a project's Settings
|
|
8
|
+
// page and the workspace's offer it: what the token is for, then the two ways to one side by
|
|
9
|
+
// side, a code approved at SkillCDN and a token made there by hand. Takes its data through the
|
|
10
|
+
// callbacks and keeps nothing itself; whose token it is, the page says in the title and the intro.
|
|
11
|
+
|
|
12
|
+
export interface PackTokenWaysProps {
|
|
13
|
+
/** What the section is called, and what assistive technology reads it as. */
|
|
14
|
+
readonly title: string;
|
|
15
|
+
/** What the token is for, in the words of the page it is on. */
|
|
16
|
+
readonly intro: string;
|
|
17
|
+
/** Whether a token is held now, and since when. */
|
|
18
|
+
readonly set: boolean;
|
|
19
|
+
readonly setAt?: string | undefined;
|
|
20
|
+
readonly busy?: boolean | undefined;
|
|
21
|
+
/** Asks the deployment to connect; answers what the person is shown. */
|
|
22
|
+
readonly onStart: () => Promise<RestPackConnectStarted>;
|
|
23
|
+
/** How the connection stands; the token is kept when the person approved. */
|
|
24
|
+
readonly poll: (signal?: AbortSignal) => Promise<PackConnectState>;
|
|
25
|
+
/** Gives the connection up. */
|
|
26
|
+
readonly onCancelConnect: () => Promise<void>;
|
|
27
|
+
/** Called once the token is kept. */
|
|
28
|
+
readonly onConnected?: (() => void) | undefined;
|
|
29
|
+
/** Called with a token made by hand, as typed or pasted. */
|
|
30
|
+
readonly onSubmit: (token: string) => void;
|
|
31
|
+
readonly onRemove?: (() => void) | undefined;
|
|
32
|
+
/** Leaves the page without changing anything. */
|
|
33
|
+
readonly onBack?: (() => void) | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function PackTokenWays(props: PackTokenWaysProps) {
|
|
37
|
+
const words = useWords();
|
|
38
|
+
const pack = words.projects.packToken;
|
|
39
|
+
return (
|
|
40
|
+
<section className="sc-panel sc-panel-form" aria-label={props.title}>
|
|
41
|
+
<h2 className="sc-section-title">{props.title}</h2>
|
|
42
|
+
<p className="sc-pack-intro">{props.intro}</p>
|
|
43
|
+
<div className="sc-pack-ways">
|
|
44
|
+
<section className="sc-pack-way" aria-label={pack.connectTitle}>
|
|
45
|
+
<h3 className="sc-pack-way-title">{pack.connectTitle}</h3>
|
|
46
|
+
<PackConnect
|
|
47
|
+
onStart={props.onStart}
|
|
48
|
+
poll={props.poll}
|
|
49
|
+
onCancel={props.onCancelConnect}
|
|
50
|
+
onConnected={props.onConnected}
|
|
51
|
+
busy={props.busy}
|
|
52
|
+
/>
|
|
53
|
+
</section>
|
|
54
|
+
<section className="sc-pack-way" aria-label={pack.pasteTitle}>
|
|
55
|
+
<h3 className="sc-pack-way-title">{pack.pasteTitle}</h3>
|
|
56
|
+
<PackTokenForm
|
|
57
|
+
set={props.set}
|
|
58
|
+
setAt={props.setAt}
|
|
59
|
+
busy={props.busy}
|
|
60
|
+
onSubmit={props.onSubmit}
|
|
61
|
+
onRemove={props.onRemove}
|
|
62
|
+
/>
|
|
63
|
+
</section>
|
|
64
|
+
</div>
|
|
65
|
+
{props.onBack !== undefined && (
|
|
66
|
+
<div className="sc-form-actions sc-pack-actions">
|
|
67
|
+
<Button variant="ghost" onClick={props.onBack}>
|
|
68
|
+
{words.common.cancel}
|
|
69
|
+
</Button>
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
</section>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type KeyboardEvent,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useEffect,
|
|
5
|
+
useId,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { useWords } from "../i18n/index.js";
|
|
11
|
+
import { cx } from "./ui.js";
|
|
12
|
+
|
|
13
|
+
// Choosing one thing out of a listing too long to offer whole (ADR-0037): the task a new one is
|
|
14
|
+
// part of, the goal it works towards. The picker asks for what the words typed find, a page at a
|
|
15
|
+
// time, and never holds the listing: the first page as soon as the field is entered, the words'
|
|
16
|
+
// page once the typing rests, the next page on a press. What is chosen stands in the field's
|
|
17
|
+
// place, and a press clears it. Takes a search and nothing else from the network.
|
|
18
|
+
|
|
19
|
+
/** How many items one page of a picker holds: enough to find the one meant, few enough to read at a glance. */
|
|
20
|
+
export const PICKER_PAGE_LIMIT = 10;
|
|
21
|
+
/** How long the typing rests before the words are searched. */
|
|
22
|
+
const SEARCH_DELAY_MS = 200;
|
|
23
|
+
|
|
24
|
+
export interface PickerPage<T> {
|
|
25
|
+
readonly items: readonly T[];
|
|
26
|
+
readonly more: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What the words find, from `offset` on; the first page of everything while the words are empty. */
|
|
30
|
+
export type PickerSearch<T> = (
|
|
31
|
+
query: string,
|
|
32
|
+
page: { readonly limit: number; readonly offset: number },
|
|
33
|
+
signal: AbortSignal,
|
|
34
|
+
) => Promise<PickerPage<T>>;
|
|
35
|
+
|
|
36
|
+
/** How far the list stands from the field, in pixels, as the stylesheet has it. */
|
|
37
|
+
const LIST_GAP = 4;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether the list opens above the field: only where the window has no room for it below the
|
|
41
|
+
* field and more room above than below; else below, as a list does.
|
|
42
|
+
*/
|
|
43
|
+
export function opensAbove(
|
|
44
|
+
field: { readonly top: number; readonly bottom: number },
|
|
45
|
+
listHeight: number,
|
|
46
|
+
windowHeight: number,
|
|
47
|
+
): boolean {
|
|
48
|
+
const below = windowHeight - field.bottom - LIST_GAP;
|
|
49
|
+
const over = field.top - LIST_GAP;
|
|
50
|
+
return listHeight > below && over > below;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PickerProps<T> {
|
|
54
|
+
/** What is chosen, if anything. */
|
|
55
|
+
readonly value: T | undefined;
|
|
56
|
+
/** What stands for a choice made but not read back yet: shown in the chip's place, and cleared as one. */
|
|
57
|
+
readonly held?: ReactNode | undefined;
|
|
58
|
+
readonly search: PickerSearch<T>;
|
|
59
|
+
readonly keyOf: (item: T) => string;
|
|
60
|
+
/** How an item reads, in the list and once chosen. */
|
|
61
|
+
readonly render: (item: T) => ReactNode;
|
|
62
|
+
readonly onChange: (item: T | undefined) => void;
|
|
63
|
+
/** The field's name, for the input and the list. */
|
|
64
|
+
readonly label: string;
|
|
65
|
+
readonly placeholder: string;
|
|
66
|
+
readonly disabled?: boolean | undefined;
|
|
67
|
+
/** What the search may answer with but cannot be chosen: a task, as its own parent. */
|
|
68
|
+
readonly exclude?: ((item: T) => boolean) | undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function Picker<T>(props: PickerProps<T>) {
|
|
72
|
+
const words = useWords().common;
|
|
73
|
+
const listId = useId();
|
|
74
|
+
const [query, setQuery] = useState("");
|
|
75
|
+
const [open, setOpen] = useState(false);
|
|
76
|
+
const [items, setItems] = useState<readonly T[]>([]);
|
|
77
|
+
const [more, setMore] = useState(false);
|
|
78
|
+
const [loading, setLoading] = useState(false);
|
|
79
|
+
const [active, setActive] = useState(-1);
|
|
80
|
+
const [above, setAbove] = useState(false);
|
|
81
|
+
const field = useRef<HTMLInputElement>(null);
|
|
82
|
+
const list = useRef<HTMLDivElement>(null);
|
|
83
|
+
// The search and the exclusion as the page last gave them, read at the moment of a request,
|
|
84
|
+
// so that a page rendering again starts no search over.
|
|
85
|
+
const search = useRef(props.search);
|
|
86
|
+
search.current = props.search;
|
|
87
|
+
const exclude = useRef(props.exclude);
|
|
88
|
+
exclude.current = props.exclude;
|
|
89
|
+
const latest = useRef(query);
|
|
90
|
+
latest.current = query;
|
|
91
|
+
|
|
92
|
+
// The list opens below the field, and above it only where the window has no room below and
|
|
93
|
+
// more above: measured before anything is painted, once it is open and again whenever what it
|
|
94
|
+
// holds changes its height, so that it never shows below for a frame on its way up.
|
|
95
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the items, the loading and the more are what changes the list's height
|
|
96
|
+
useLayoutEffect(() => {
|
|
97
|
+
if (!open || field.current === null || list.current === null) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
setAbove(
|
|
101
|
+
opensAbove(
|
|
102
|
+
field.current.getBoundingClientRect(),
|
|
103
|
+
list.current.offsetHeight,
|
|
104
|
+
window.innerHeight,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
}, [open, items, loading, more]);
|
|
108
|
+
|
|
109
|
+
// The words' page once the typing rests; the first page of everything at once on entering.
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!open) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
const timer = setTimeout(
|
|
116
|
+
() => {
|
|
117
|
+
setLoading(true);
|
|
118
|
+
search
|
|
119
|
+
.current(query, { limit: PICKER_PAGE_LIMIT, offset: 0 }, controller.signal)
|
|
120
|
+
.then((page) => {
|
|
121
|
+
if (!controller.signal.aborted) {
|
|
122
|
+
setItems(page.items.filter((item) => exclude.current?.(item) !== true));
|
|
123
|
+
setMore(page.more);
|
|
124
|
+
setActive(-1);
|
|
125
|
+
setLoading(false);
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
.catch(() => {
|
|
129
|
+
if (!controller.signal.aborted) {
|
|
130
|
+
setItems([]);
|
|
131
|
+
setMore(false);
|
|
132
|
+
setLoading(false);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
query === "" ? 0 : SEARCH_DELAY_MS,
|
|
137
|
+
);
|
|
138
|
+
return () => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
controller.abort();
|
|
141
|
+
};
|
|
142
|
+
}, [open, query]);
|
|
143
|
+
|
|
144
|
+
/** The next page of the same words, put under the ones shown; dropped when the words moved on. */
|
|
145
|
+
const loadMore = () => {
|
|
146
|
+
const wanted = query;
|
|
147
|
+
setLoading(true);
|
|
148
|
+
search
|
|
149
|
+
.current(
|
|
150
|
+
wanted,
|
|
151
|
+
{ limit: PICKER_PAGE_LIMIT, offset: items.length },
|
|
152
|
+
new AbortController().signal,
|
|
153
|
+
)
|
|
154
|
+
.then((page) => {
|
|
155
|
+
if (wanted !== latest.current) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
setItems((current) => [
|
|
159
|
+
...current,
|
|
160
|
+
...page.items.filter((item) => exclude.current?.(item) !== true),
|
|
161
|
+
]);
|
|
162
|
+
setMore(page.more);
|
|
163
|
+
setLoading(false);
|
|
164
|
+
})
|
|
165
|
+
.catch(() => {
|
|
166
|
+
if (wanted === latest.current) {
|
|
167
|
+
setMore(false);
|
|
168
|
+
setLoading(false);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const choose = (item: T) => {
|
|
174
|
+
props.onChange(item);
|
|
175
|
+
setQuery("");
|
|
176
|
+
setOpen(false);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
|
180
|
+
if (event.key === "ArrowDown") {
|
|
181
|
+
event.preventDefault();
|
|
182
|
+
if (!open) {
|
|
183
|
+
setOpen(true);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
setActive((current) => (items.length === 0 ? -1 : (current + 1) % items.length));
|
|
187
|
+
} else if (event.key === "ArrowUp") {
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
setActive((current) =>
|
|
190
|
+
items.length === 0 ? -1 : current <= 0 ? items.length - 1 : current - 1,
|
|
191
|
+
);
|
|
192
|
+
} else if (event.key === "Enter") {
|
|
193
|
+
// A choice, or nothing: the form around the field is never sent by this key.
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
const chosen = active >= 0 ? items[active] : undefined;
|
|
196
|
+
if (chosen !== undefined) {
|
|
197
|
+
choose(chosen);
|
|
198
|
+
}
|
|
199
|
+
} else if (event.key === "Escape" && open) {
|
|
200
|
+
event.preventDefault();
|
|
201
|
+
setOpen(false);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
if (props.value !== undefined || props.held !== undefined) {
|
|
206
|
+
return (
|
|
207
|
+
<div className="sc-picker sc-picker-chosen">
|
|
208
|
+
<span className="sc-picker-value">
|
|
209
|
+
{props.value === undefined ? props.held : props.render(props.value)}
|
|
210
|
+
</span>
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
className="sc-picker-clear"
|
|
214
|
+
aria-label={words.remove}
|
|
215
|
+
disabled={props.disabled}
|
|
216
|
+
onClick={() => props.onChange(undefined)}
|
|
217
|
+
>
|
|
218
|
+
<span aria-hidden="true">×</span>
|
|
219
|
+
</button>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<div className="sc-picker">
|
|
226
|
+
<input
|
|
227
|
+
ref={field}
|
|
228
|
+
className="sc-input"
|
|
229
|
+
type="text"
|
|
230
|
+
role="combobox"
|
|
231
|
+
aria-label={props.label}
|
|
232
|
+
aria-expanded={open}
|
|
233
|
+
aria-controls={listId}
|
|
234
|
+
aria-autocomplete="list"
|
|
235
|
+
aria-activedescendant={active >= 0 ? `${listId}-${active}` : undefined}
|
|
236
|
+
autoComplete="off"
|
|
237
|
+
placeholder={props.placeholder}
|
|
238
|
+
value={query}
|
|
239
|
+
disabled={props.disabled}
|
|
240
|
+
onChange={(event) => {
|
|
241
|
+
setQuery(event.target.value);
|
|
242
|
+
setOpen(true);
|
|
243
|
+
}}
|
|
244
|
+
onFocus={() => setOpen(true)}
|
|
245
|
+
onBlur={() => setOpen(false)}
|
|
246
|
+
onKeyDown={onKeyDown}
|
|
247
|
+
/>
|
|
248
|
+
{open && (
|
|
249
|
+
// The list of a combobox: read and chosen through the input, which keeps the focus; an
|
|
250
|
+
// option is chosen on the press, before the input loses its focus and the list closes.
|
|
251
|
+
<div
|
|
252
|
+
ref={list}
|
|
253
|
+
className={cx("sc-picker-list", above && "sc-picker-list-above")}
|
|
254
|
+
id={listId}
|
|
255
|
+
role="listbox"
|
|
256
|
+
aria-label={props.label}
|
|
257
|
+
>
|
|
258
|
+
{items.map((item, index) => (
|
|
259
|
+
<div
|
|
260
|
+
key={props.keyOf(item)}
|
|
261
|
+
id={`${listId}-${index}`}
|
|
262
|
+
role="option"
|
|
263
|
+
tabIndex={-1}
|
|
264
|
+
aria-selected={index === active}
|
|
265
|
+
className={cx("sc-picker-option", index === active && "sc-picker-option-active")}
|
|
266
|
+
onMouseDown={(event) => {
|
|
267
|
+
event.preventDefault();
|
|
268
|
+
choose(item);
|
|
269
|
+
}}
|
|
270
|
+
onMouseEnter={() => setActive(index)}
|
|
271
|
+
>
|
|
272
|
+
{props.render(item)}
|
|
273
|
+
</div>
|
|
274
|
+
))}
|
|
275
|
+
{!loading && items.length === 0 && (
|
|
276
|
+
<div className="sc-picker-note sc-muted">{words.picker.nothingFound}</div>
|
|
277
|
+
)}
|
|
278
|
+
{loading && <div className="sc-picker-note sc-muted">{words.picker.searching}</div>}
|
|
279
|
+
{more && !loading && (
|
|
280
|
+
<div className="sc-picker-note">
|
|
281
|
+
<button
|
|
282
|
+
type="button"
|
|
283
|
+
className="sc-button sc-button-ghost sc-button-sm"
|
|
284
|
+
onMouseDown={(event) => {
|
|
285
|
+
event.preventDefault();
|
|
286
|
+
loadMore();
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
{words.picker.more}
|
|
290
|
+
</button>
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
}
|