@skillcdn/console 0.3.1 → 0.4.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/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 +4 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +201 -25
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +15 -11
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +59 -26
- 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/cli/pack.d.ts +7 -2
- package/dist/cli/pack.d.ts.map +1 -1
- package/dist/cli/pack.js +16 -2
- package/dist/cli/pack.js.map +1 -1
- package/dist/client.d.ts +20 -10
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +17 -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 +12 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +57 -7
- package/dist/components/board.js.map +1 -1
- package/dist/components/calendar.d.ts +17 -7
- package/dist/components/calendar.d.ts.map +1 -1
- package/dist/components/calendar.js +89 -38
- package/dist/components/calendar.js.map +1 -1
- package/dist/components/decision-form.d.ts +12 -4
- package/dist/components/decision-form.d.ts.map +1 -1
- package/dist/components/decision-form.js +24 -6
- package/dist/components/decision-form.js.map +1 -1
- package/dist/components/decision-list.d.ts +4 -0
- package/dist/components/decision-list.d.ts.map +1 -1
- package/dist/components/decision-list.js +9 -6
- package/dist/components/decision-list.js.map +1 -1
- package/dist/components/documents.d.ts.map +1 -1
- package/dist/components/documents.js +3 -3
- package/dist/components/documents.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +63 -8
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/my-decisions.d.ts +20 -0
- package/dist/components/my-decisions.d.ts.map +1 -0
- package/dist/components/my-decisions.js +36 -0
- package/dist/components/my-decisions.js.map +1 -0
- package/dist/components/people.d.ts.map +1 -1
- package/dist/components/people.js +1 -1
- package/dist/components/people.js.map +1 -1
- 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.map +1 -1
- package/dist/components/projects.js +3 -3
- package/dist/components/projects.js.map +1 -1
- package/dist/components/roadmap.js +1 -1
- package/dist/components/roadmap.js.map +1 -1
- package/dist/components/runs.d.ts.map +1 -1
- package/dist/components/runs.js +1 -1
- package/dist/components/runs.js.map +1 -1
- package/dist/components/shell.d.ts.map +1 -1
- package/dist/components/shell.js +36 -1
- package/dist/components/shell.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 +7 -6
- package/dist/components/task-view.js.map +1 -1
- package/dist/components/ui.d.ts +17 -0
- package/dist/components/ui.d.ts.map +1 -1
- package/dist/components/ui.js +26 -11
- package/dist/components/ui.js.map +1 -1
- package/dist/components/work.d.ts.map +1 -1
- package/dist/components/work.js +5 -3
- package/dist/components/work.js.map +1 -1
- package/dist/console.css +1794 -433
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +93 -10
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +21 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +24 -1
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +45 -3
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +52 -10
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +54 -12
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- 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 +9 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +12 -0
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +3 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +3 -1
- package/dist/routes.js.map +1 -1
- package/dist/rules.js +1 -1
- package/dist/rules.js.map +1 -1
- package/dist/schemas.d.ts +252 -12
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +27 -10
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +2 -0
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +2 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +14 -13
- package/docs/specs/rest.md +24 -19
- package/package.json +2 -2
- package/skills/building-a-console/SKILL.md +4 -4
- package/skills/building-a-console/references/the-default-console.md +4 -4
- package/skills/building-a-console/references/the-look-from-a-website.md +5 -5
- package/skills/deploying-a-console/SKILL.md +2 -2
- package/skills/deploying-a-console/references/resources.md +1 -1
- package/skills/working-the-board/SKILL.md +8 -6
- package/skills/working-the-board/references/terms.md +3 -3
- package/src/api.ts +7 -0
- package/src/cli/cli.ts +254 -25
- package/src/cli/format.ts +22 -11
- package/src/cli/help.ts +63 -26
- package/src/cli/main.ts +1 -0
- package/src/cli/pack.ts +17 -1
- package/src/client.ts +44 -11
- package/src/compat.ts +63 -14
- package/src/components/board.tsx +124 -25
- package/src/components/calendar.tsx +258 -117
- package/src/components/decision-form.tsx +71 -23
- package/src/components/decision-list.tsx +30 -3
- package/src/components/documents.tsx +27 -11
- package/src/components/event-feed.tsx +116 -33
- package/src/components/my-decisions.tsx +138 -0
- package/src/components/people.tsx +3 -1
- package/src/components/picker.tsx +297 -0
- package/src/components/projects.tsx +7 -5
- package/src/components/roadmap.tsx +1 -1
- package/src/components/runs.tsx +5 -1
- package/src/components/shell.tsx +36 -1
- package/src/components/task-form.tsx +149 -63
- package/src/components/task-list.tsx +3 -3
- package/src/components/task-view.tsx +71 -56
- package/src/components/ui.tsx +89 -11
- package/src/components/work.tsx +6 -4
- package/src/console.tsx +157 -29
- package/src/data.ts +61 -1
- package/src/i18n/en.ts +54 -10
- package/src/i18n/ko.ts +56 -12
- package/src/index.ts +19 -1
- package/src/limits.ts +2 -0
- package/src/router.ts +18 -1
- package/src/routes.ts +3 -1
- package/src/rules.ts +1 -1
- package/src/schemas.ts +29 -9
- package/src/styles/console.css +1794 -433
- package/src/vocabulary.ts +3 -0
|
@@ -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
|
+
}
|
|
@@ -93,7 +93,7 @@ export const VISIBILITY_LABELS: Readonly<Record<ProjectVisibility, string>> = {
|
|
|
93
93
|
export function ProjectRoleBadge(props: { readonly role: ProjectRole }) {
|
|
94
94
|
const words = useWords();
|
|
95
95
|
return (
|
|
96
|
-
<Badge tone={props.role === "owner" ? "
|
|
96
|
+
<Badge tone={props.role === "owner" ? "accent" : "neutral"}>
|
|
97
97
|
{words.vocabulary.projectRole[props.role]}
|
|
98
98
|
</Badge>
|
|
99
99
|
);
|
|
@@ -143,7 +143,9 @@ export function ProjectList(props: ProjectListProps) {
|
|
|
143
143
|
)}
|
|
144
144
|
{project.openDecisions > 0 && (
|
|
145
145
|
<p className="sc-project-meta">
|
|
146
|
-
<Badge tone="warning">
|
|
146
|
+
<Badge tone="warning" kind="count">
|
|
147
|
+
{words.decisionsWaiting(project.openDecisions)}
|
|
148
|
+
</Badge>
|
|
147
149
|
</p>
|
|
148
150
|
)}
|
|
149
151
|
</li>
|
|
@@ -444,11 +446,11 @@ export function MemberList(props: MemberListProps) {
|
|
|
444
446
|
{props.members.map((member) => (
|
|
445
447
|
<li key={member.person.id} className="sc-person-row">
|
|
446
448
|
<PersonChip person={member.person} size="md" />
|
|
449
|
+
{props.me?.id === member.person.id && (
|
|
450
|
+
<span className="sc-person-you">{words.common.you}</span>
|
|
451
|
+
)}
|
|
447
452
|
<span className="sc-person-name">
|
|
448
453
|
{member.person.name ?? ""}
|
|
449
|
-
{props.me?.id === member.person.id && (
|
|
450
|
-
<span className="sc-muted"> {words.common.you}</span>
|
|
451
|
-
)}
|
|
452
454
|
<span className="sc-muted">
|
|
453
455
|
{" "}
|
|
454
456
|
{members.since} <Time iso={member.addedAt} />
|
|
@@ -157,7 +157,7 @@ export function GoalTimeline(props: GoalTimelineProps) {
|
|
|
157
157
|
return (
|
|
158
158
|
<div key={goal.id} className={cx("sc-timeline-row", `sc-goal-${goal.state}`)}>
|
|
159
159
|
<div className="sc-timeline-label">
|
|
160
|
-
<a className="sc-timeline-goal" href={props.goalHref(goal)}>
|
|
160
|
+
<a className="sc-timeline-goal" href={props.goalHref(goal)} title={goal.title}>
|
|
161
161
|
{goal.title}
|
|
162
162
|
</a>
|
|
163
163
|
<GoalStateBadge state={goal.state} />
|
package/src/components/runs.tsx
CHANGED
|
@@ -32,7 +32,11 @@ export function RunStatusBadge(props: { readonly status: RunStatus }) {
|
|
|
32
32
|
: props.status === "failed"
|
|
33
33
|
? "danger"
|
|
34
34
|
: "neutral";
|
|
35
|
-
return
|
|
35
|
+
return (
|
|
36
|
+
<Badge tone={tone} kind="state">
|
|
37
|
+
{words.vocabulary.runStatus[props.status]}
|
|
38
|
+
</Badge>
|
|
39
|
+
);
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
export interface RunCardProps {
|
package/src/components/shell.tsx
CHANGED
|
@@ -186,6 +186,41 @@ function PersonMenu(props: {
|
|
|
186
186
|
|
|
187
187
|
export function Shell(props: ShellProps) {
|
|
188
188
|
const words = useWords();
|
|
189
|
+
// Where the tabs scroll sideways, the current one is brought into the bar's view.
|
|
190
|
+
const tabs = useRef<HTMLElement>(null);
|
|
191
|
+
const current = props.nav.find((item) => item.current)?.href;
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
const bar = tabs.current;
|
|
194
|
+
if (current === undefined || bar === null) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const bring = () => {
|
|
198
|
+
const tab = bar.querySelector<HTMLElement>(".sc-tab-current");
|
|
199
|
+
if (tab === null) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const left = tab.offsetLeft - bar.offsetLeft;
|
|
203
|
+
// The last part of the bar fades where it scrolls (the stylesheet's mask): a tab there
|
|
204
|
+
// counts as out of view.
|
|
205
|
+
const seen = bar.scrollWidth > bar.clientWidth ? bar.clientWidth * 0.85 : bar.clientWidth;
|
|
206
|
+
if (left < bar.scrollLeft || left + tab.offsetWidth > bar.scrollLeft + seen) {
|
|
207
|
+
bar.scrollLeft = left - (bar.clientWidth - tab.offsetWidth) / 2;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
bring();
|
|
211
|
+
// The tabs grow once the page's face has loaded, and the bar narrows with the window: either
|
|
212
|
+
// may put the current tab out of view again.
|
|
213
|
+
if (typeof ResizeObserver === "undefined") {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const watch = new ResizeObserver(bring);
|
|
217
|
+
watch.observe(bar);
|
|
218
|
+
const inner = bar.firstElementChild;
|
|
219
|
+
if (inner !== null) {
|
|
220
|
+
watch.observe(inner);
|
|
221
|
+
}
|
|
222
|
+
return () => watch.disconnect();
|
|
223
|
+
}, [current]);
|
|
189
224
|
const follow = (href: string) => (event: React.MouseEvent) => {
|
|
190
225
|
if (isPlainClick(event)) {
|
|
191
226
|
event.preventDefault();
|
|
@@ -259,7 +294,7 @@ export function Shell(props: ShellProps) {
|
|
|
259
294
|
</div>
|
|
260
295
|
</div>
|
|
261
296
|
{props.nav.length > 0 && (
|
|
262
|
-
<nav className="sc-tabs" aria-label={words.shell.pages}>
|
|
297
|
+
<nav ref={tabs} className="sc-tabs" aria-label={words.shell.pages}>
|
|
263
298
|
<div className="sc-tabs-inner">
|
|
264
299
|
{props.nav.map((item) => (
|
|
265
300
|
<a
|