wings-plugin-sdk 0.1.0 → 0.2.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/ui.js ADDED
@@ -0,0 +1,290 @@
1
+ // wings-plugin-sdk/ui: a page says what to show and where, and Wings decides how it looks. Every component renders
2
+ // plain elements with `w-*` class names and data attributes that Wings' plugin-ui.css styles, never a style of its
3
+ // own, so a Wings update restyles every plugin. Your own components and CSS work alongside them.
4
+ import { createContext, createElement as h, useContext, useEffect, useId, useState } from "react";
5
+ import { createRoot } from "react-dom/client";
6
+
7
+ import { mountShell } from "./shell.js";
8
+
9
+ export { mountShell } from "./shell.js";
10
+ export { useBroadcast } from "./react.js";
11
+
12
+ // ---------- places ----------
13
+
14
+ let root = null;
15
+
16
+ function show(place, element, options) {
17
+ const shell = mountShell(place, options);
18
+ root ??= createRoot(shell);
19
+ root.render(element);
20
+ }
21
+
22
+ /** Shows `element` as the page of a sidebar. One call per page; calling again renders the new element. */
23
+ export const showSidebar = (element, options) => show("sidebar", element, options);
24
+ /** Shows `element` as a view's page, in a modal or a window of its own. */
25
+ export const showView = (element, options) => show("view", element, options);
26
+ /** Shows `element` as a view's page in a tab next to the terminals. */
27
+ export const showTab = (element, options) => show("tab", element, options);
28
+ /** Shows `element` as a sidebar's popover. */
29
+ export const showPopover = (element, options) => show("popover", element, options);
30
+ /** Shows `element` as the plugin's page in Wings Settings. */
31
+ export const showSettings = (element, options) => show("settings", element, options);
32
+
33
+ // ---------- talking to main ----------
34
+
35
+ const HELLO = { type: "hello" };
36
+
37
+ /**
38
+ * The newest message of `type` the plugin's other pages broadcast, or null until one comes. Sends `hello` once
39
+ * when the component mounts, so main answers with its state; pass null to send nothing.
40
+ */
41
+ export function useMainState(type, hello = HELLO) {
42
+ const [state, setState] = useState(null);
43
+ useEffect(() => {
44
+ const off = wings.onBroadcast((message) => {
45
+ if (message?.type === type) setState(message);
46
+ });
47
+ if (hello != null) void wings.broadcast(hello);
48
+ return off;
49
+ // `hello` is only sent when the component mounts.
50
+ }, [type]);
51
+ return state;
52
+ }
53
+
54
+ // ---------- helpers ----------
55
+
56
+ const cx = (base, extra) => (extra ? `${base} ${extra}` : base);
57
+ /** A data attribute that's there or not. */
58
+ const flag = (on) => (on ? "" : undefined);
59
+
60
+ // ---------- layout and text ----------
61
+
62
+ /** Children side by side, centred. `gap` is none, xs, s, m (default) or l. */
63
+ export function Row({ as = "div", gap, align, justify, wrap, grow, className, ...rest }) {
64
+ return h(as, { ...rest, className: cx("w-row", className), "data-gap": gap, "data-align": align, "data-justify": justify, "data-wrap": flag(wrap), "data-grow": flag(grow) });
65
+ }
66
+
67
+ /** Children one under the other. */
68
+ export function Stack({ as = "div", gap, grow, className, ...rest }) {
69
+ return h(as, { ...rest, className: cx("w-stack", className), "data-gap": gap, "data-grow": flag(grow) });
70
+ }
71
+
72
+ /** Text with a tone and size, that can grow to fill a row or truncate with an ellipsis. */
73
+ export function Text({ as = "span", tone, size, weight, mono, numeric, truncate, clamp, grow, className, ...rest }) {
74
+ return h(as, {
75
+ ...rest,
76
+ className: cx("w-text", className),
77
+ "data-tone": tone,
78
+ "data-size": size,
79
+ "data-weight": weight,
80
+ "data-mono": flag(mono),
81
+ "data-numeric": flag(numeric),
82
+ "data-truncate": flag(truncate),
83
+ "data-clamp": flag(clamp),
84
+ "data-grow": flag(grow),
85
+ });
86
+ }
87
+
88
+ /** A section heading, with `actions` like chips or a button on its right. */
89
+ export function Heading({ actions, className, ...rest }) {
90
+ const heading = h("h2", { ...rest, className: cx("w-heading", className) });
91
+ return actions ? h("div", { className: "w-heading-row" }, heading, actions) : heading;
92
+ }
93
+
94
+ /** A card. `compact` for a summary line. */
95
+ export function Card({ as = "div", compact, className, ...rest }) {
96
+ return h(as, { ...rest, className: cx("w-card", className), "data-size": compact ? "compact" : undefined });
97
+ }
98
+
99
+ // ---------- figures ----------
100
+
101
+ const levelTone = (percent, levels) => (!levels ? undefined : percent >= levels[1] ? "danger" : percent >= levels[0] ? "warn" : undefined);
102
+
103
+ /** A bar from 0 to 100. `tone` is warn or danger. */
104
+ export function Meter({ percent, label, tone, className, ...rest }) {
105
+ return h(
106
+ "div",
107
+ { ...rest, className: cx("w-meter", className), role: "meter", "aria-label": label, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": percent, "data-tone": tone },
108
+ // How full it is is data, not a style: plugin-ui.css sizes the bar from it.
109
+ h("span", { className: "w-meter-fill", style: { "--w-value": `${Math.max(0, Math.min(100, percent))}%` } }),
110
+ );
111
+ }
112
+
113
+ /** A labelled bar with its percentage and a note under it. `levels` are where it turns warn and danger. */
114
+ export function Gauge({ label, percent, note, tone, levels, className, ...rest }) {
115
+ return h(
116
+ "div",
117
+ { ...rest, className: cx("w-gauge", className) },
118
+ h("span", { className: "w-gauge-label" }, label),
119
+ h("span", { className: "w-gauge-value" }, `${percent}%`),
120
+ h(Meter, { percent, label, tone: tone ?? levelTone(percent, levels) }),
121
+ note ? h("span", { className: "w-gauge-note" }, note) : null,
122
+ );
123
+ }
124
+
125
+ /** Small figures side by side, label over value. */
126
+ export function Stats({ items, className, ...rest }) {
127
+ return h(
128
+ "dl",
129
+ { ...rest, className: cx("w-stats", className) },
130
+ items.map((item) => h("div", { key: item.label }, h("dt", { className: "w-stat-label" }, item.label), h("dd", { className: "w-stat-value", "data-tone": item.tone, title: item.title }, item.value))),
131
+ );
132
+ }
133
+
134
+ /** Label and value pairs, one per line. */
135
+ export function Properties({ items, className, ...rest }) {
136
+ return h(
137
+ "dl",
138
+ { ...rest, className: cx("w-props", className) },
139
+ items.map((item) => h("div", { key: item.label, className: "w-prop" }, h("dt", { className: "w-prop-label" }, item.label), h("dd", { className: "w-prop-value" }, item.value))),
140
+ );
141
+ }
142
+
143
+ // ---------- lists ----------
144
+
145
+ /** A list of rows. `ordered` for an `ol`, `compact` for rows with no space between them. */
146
+ export function List({ ordered, compact, className, ...rest }) {
147
+ return h(ordered ? "ol" : "ul", { ...rest, className: cx("w-list", className), "data-density": compact ? "compact" : undefined });
148
+ }
149
+
150
+ /** A row in a `List`. Holding a `ListButton`, the button is the row. */
151
+ export function ListItem({ align, className, ...rest }) {
152
+ return h("li", { ...rest, className: cx("w-list-item", className), "data-align": align });
153
+ }
154
+
155
+ /** A row that's a button, inside a `ListItem`. */
156
+ export function ListButton({ align, type = "button", className, ...rest }) {
157
+ return h("button", { ...rest, type, className: cx("w-list-button", className), "data-align": align });
158
+ }
159
+
160
+ // ---------- status ----------
161
+
162
+ /** A status dot. With a `label` it's announced; otherwise it's decoration next to text that says the same. */
163
+ export function Dot({ tone, label, className, ...rest }) {
164
+ return h("span", { ...rest, className: cx("w-dot", className), "data-tone": tone, ...(label ? { role: "img", "aria-label": label } : { "aria-hidden": true }) });
165
+ }
166
+
167
+ /** A spinner. With a `label` it's announced. */
168
+ export function Spinner({ size, label, className, ...rest }) {
169
+ return h("span", { ...rest, className: cx("w-spinner", className), "data-size": size, ...(label ? { role: "img", "aria-label": label } : { "aria-hidden": true }) });
170
+ }
171
+
172
+ /** A filled pill, like a label on a command. */
173
+ export function Pill({ tone, className, ...rest }) {
174
+ return h("span", { ...rest, className: cx("w-pill", className), "data-tone": tone });
175
+ }
176
+
177
+ /** An outlined tag, like a branch name. */
178
+ export function Tag({ tone, className, ...rest }) {
179
+ return h("span", { ...rest, className: cx("w-tag", className), "data-tone": tone });
180
+ }
181
+
182
+ /** What shows before the first data comes. */
183
+ export function Loading({ children = "Loading…", className, ...rest }) {
184
+ return h("p", { ...rest, className: cx("w-loading", className) }, children);
185
+ }
186
+
187
+ /** Nothing to show: what's missing, and a `detail` line on what to do about it. */
188
+ export function Empty({ title, detail, className, ...rest }) {
189
+ return h("div", { ...rest, className: cx("w-empty", className) }, h("p", null, title), detail === undefined ? null : h("p", { className: "w-empty-detail" }, detail));
190
+ }
191
+
192
+ // ---------- buttons ----------
193
+
194
+ /** A button. `variant` is primary, ghost or danger; `size` small. */
195
+ export function Button({ variant, size, type = "button", className, ...rest }) {
196
+ return h("button", { ...rest, type, className: cx("w-button", className), "data-variant": variant, "data-size": size });
197
+ }
198
+
199
+ /** A small quiet button. With `pressed` it's a toggle. */
200
+ export function Chip({ pressed, type = "button", className, ...rest }) {
201
+ return h("button", { ...rest, type, className: cx("w-chip", className), "aria-pressed": pressed });
202
+ }
203
+
204
+ /** Chips that pick one of `options`, like a filter. `onChange` gets each click, the chip that's on too. */
205
+ export function Chips({ label, options, value, onChange, className, ...rest }) {
206
+ return h(
207
+ "div",
208
+ { ...rest, className: cx("w-chips", className), role: "group", "aria-label": label },
209
+ options.map((o) => h(Chip, { key: o.value, pressed: o.value === value, title: o.title, onClick: () => onChange(o.value) }, o.label)),
210
+ );
211
+ }
212
+
213
+ /** Tabs over the panel they show, `children`. Arrow keys, Home and End move between tabs. */
214
+ export function Tabs({ label, items, value, onChange, children, className, ...rest }) {
215
+ const id = useId();
216
+ const tabId = (v) => `${id}-tab-${v}`;
217
+ const onKeyDown = (e) => {
218
+ const at = items.findIndex((t) => t.value === value);
219
+ const next = { ArrowRight: at + 1, ArrowLeft: at - 1, Home: 0, End: items.length - 1 }[e.key];
220
+ if (next === undefined) return;
221
+ e.preventDefault();
222
+ const tab = items[(next + items.length) % items.length];
223
+ onChange(tab.value);
224
+ document.getElementById(tabId(tab.value))?.focus();
225
+ };
226
+ return h(
227
+ "div",
228
+ { ...rest, className },
229
+ h(
230
+ "div",
231
+ { className: "w-tabs", role: "tablist", "aria-label": label, onKeyDown },
232
+ items.map((t) =>
233
+ h("button", { key: t.value, type: "button", id: tabId(t.value), className: "w-tab", role: "tab", "aria-selected": t.value === value, "aria-controls": `${id}-panel`, tabIndex: t.value === value ? 0 : -1, onClick: () => onChange(t.value) }, t.label),
234
+ ),
235
+ ),
236
+ h("div", { id: `${id}-panel`, role: "tabpanel", "aria-labelledby": tabId(value) }, children),
237
+ );
238
+ }
239
+
240
+ // ---------- fields ----------
241
+
242
+ const FieldContext = createContext(null);
243
+
244
+ /** Gives an input in a `Field` its label and hint. */
245
+ function useFieldProps(rest) {
246
+ const field = useContext(FieldContext);
247
+ return field ? { id: field.id, "aria-describedby": field.hint, "aria-invalid": field.invalid || undefined, ...rest } : rest;
248
+ }
249
+
250
+ /** A labelled field with a `hint` or an `error` under it, for the input in `children`. */
251
+ export function Field({ label, hint, error, children, className, ...rest }) {
252
+ const id = useId();
253
+ const note = error ?? hint;
254
+ return h(
255
+ "div",
256
+ { ...rest, className: cx("w-field", className) },
257
+ h("label", { className: "w-field-label", htmlFor: id }, label),
258
+ h(FieldContext.Provider, { value: { id, hint: note ? `${id}-hint` : undefined, invalid: Boolean(error) } }, children),
259
+ note ? h("p", { id: `${id}-hint`, className: "w-field-hint", "data-tone": error ? "danger" : undefined }, note) : null,
260
+ );
261
+ }
262
+
263
+ /** A text input. Outside a `Field`, give it a `label`. */
264
+ export function TextInput({ label, type = "text", className, ...rest }) {
265
+ return h("input", { "aria-label": label, ...useFieldProps(rest), type, className: cx("w-input", className) });
266
+ }
267
+
268
+ /** A search box. `label` names it for screen readers. */
269
+ export function SearchField({ label, className, ...rest }) {
270
+ return h("input", { "aria-label": label, ...useFieldProps(rest), type: "search", className: cx("w-search", className) });
271
+ }
272
+
273
+ /** A select of `options`. `onChange` gets the value picked. */
274
+ export function Select({ label, options, onChange, className, ...rest }) {
275
+ return h(
276
+ "select",
277
+ { "aria-label": label, ...useFieldProps(rest), className: cx("w-select", className), onChange: (e) => onChange?.(e.currentTarget.value) },
278
+ options.map((o) => h("option", { key: o.value, value: o.value, disabled: o.disabled }, o.label)),
279
+ );
280
+ }
281
+
282
+ /** A checkbox with its label, `children`. `onChange` gets whether it's checked. */
283
+ export function Checkbox({ onChange, children, className, ...rest }) {
284
+ return h("label", { className: cx("w-check", className) }, h("input", { ...rest, type: "checkbox", onChange: (e) => onChange?.(e.currentTarget.checked) }), children);
285
+ }
286
+
287
+ /** More behind a `summary` line that opens it. */
288
+ export function Disclosure({ summary, children, className, ...rest }) {
289
+ return h("details", { ...rest, className: cx("w-disclosure", className) }, h("summary", null, summary), children);
290
+ }
package/wings.d.ts CHANGED
@@ -204,12 +204,15 @@ interface Wings {
204
204
  setSidebarLabel(sidebarId: string, label: string | null, options?: { rows?: { label: string; value: string }[]; tone?: "warning" | "danger" }): Promise<void>;
205
205
  /**
206
206
  * Opens one of `contributes.sidebars` or `contributes.views` by id: in the right sidebar, in a modal over Wings,
207
- * or in a window of its own. `as` must be in the view's `opens`, and defaults to the first. A window that's
208
- * already open comes to the front. The page gets the same `wings` object wherever it opens.
207
+ * in a window of its own or as a tab in the project on screen (`tab` needs Wings 0.1.15). `as` must be in the
208
+ * view's `opens`, and defaults to the first. A window or tab that's already open comes to the front. The page
209
+ * gets the same `wings` object wherever it opens.
209
210
  */
210
- openView(id: string, options?: { as?: "sidebar" | "modal" | "window" }): Promise<void>;
211
+ openView(id: string, options?: { as?: "sidebar" | "modal" | "window" | "tab" }): Promise<void>;
211
212
  /** Closes a sidebar or view wherever it's open. */
212
213
  closeView(id: string): Promise<void>;
214
+ /** Opens Wings Settings at the plugin's settings page, `contributes.settings`. Wings 0.1.15 and newer. */
215
+ openSettings(): Promise<void>;
213
216
  /** Sends a JSON value, up to 64 KB, to the plugin's other pages: its main script and open sidebars, modals and windows. */
214
217
  broadcast(message: unknown): Promise<void>;
215
218
  /** Gets what the plugin's other pages send with `broadcast`. */
@@ -217,3 +220,6 @@ interface Wings {
217
220
  }
218
221
 
219
222
  declare const wings: Wings;
223
+
224
+ /** A page's own CSS, `import "./sidebar.css"`: wings-plugin build bundles it into the page's `<name>.css`. */
225
+ declare module "*.css";