@flung.dev/connect 0.1.2 → 0.3.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 +221 -12
- package/dist/core/client.d.ts +6 -2
- package/dist/core/client.js +26 -10
- package/dist/core/controller.d.ts +214 -0
- package/dist/core/controller.js +407 -0
- package/dist/core/events.d.ts +25 -0
- package/dist/core/events.js +1 -0
- package/dist/core/index.d.ts +10 -4
- package/dist/core/index.js +6 -3
- package/dist/core/labels.d.ts +78 -0
- package/dist/core/labels.js +41 -0
- package/dist/core/platforms.d.ts +7 -1
- package/dist/core/platforms.js +11 -1
- package/dist/core/types.d.ts +20 -0
- package/dist/core/types.js +1 -0
- package/dist/element/copy.d.ts +3 -39
- package/dist/element/copy.js +2 -32
- package/dist/element/dom.d.ts +24 -8
- package/dist/element/dom.js +40 -8
- package/dist/element/events.d.ts +14 -24
- package/dist/element/flung-connect.d.ts +21 -1
- package/dist/element/flung-connect.js +215 -199
- package/dist/element/marks.d.ts +7 -2
- package/dist/element/marks.js +8 -1
- package/dist/element/styles.d.ts +5 -3
- package/dist/element/styles.js +151 -42
- package/dist/element/tooltip.d.ts +26 -0
- package/dist/element/tooltip.js +135 -0
- package/dist/index.d.ts +2 -4
- package/dist/index.js +0 -1
- package/dist/react/index.d.ts +43 -3
- package/dist/react/index.js +39 -2
- package/dist/react/use-flung-connect.d.ts +13 -0
- package/dist/react/use-flung-connect.js +65 -0
- package/package.json +1 -1
|
@@ -1,25 +1,38 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import { createConnectController, initialConnectState } from '../core/controller.js';
|
|
2
|
+
import { DEFAULT_LABELS, formatLabel as format } from '../core/labels.js';
|
|
3
|
+
import { parsePlatformList } from '../core/platforms.js';
|
|
4
|
+
import { avatar, button, html, mark } from './dom.js';
|
|
5
|
+
import { CHOICE_MARKS, FLUNG_MARK, PLATFORM_MARKS } from './marks.js';
|
|
6
|
+
import { BASE_STYLES, STYLES } from './styles.js';
|
|
7
|
+
import { panelTooltip, TOOLTIP_BASE_STYLES, TOOLTIP_STYLES } from './tooltip.js';
|
|
8
|
+
const LAYOUTS = ['list', 'grid', 'compact'];
|
|
9
|
+
/**
|
|
10
|
+
* Which of a platform's tiles its step shows under: a reconnect or a disconnect under the account's tile, a connect under
|
|
11
|
+
* the tile of the variant it is for, anything else under the first. -1 when none matches.
|
|
12
|
+
*/
|
|
13
|
+
function stepTile(item, tiles) {
|
|
14
|
+
const { step } = item;
|
|
15
|
+
const accountId = step.kind === 'confirm' ? step.account.account.id : step.kind === 'waiting' ? step.accountId : null;
|
|
16
|
+
if (accountId !== null)
|
|
17
|
+
return tiles.findIndex((tile) => tile.accounts.some((account) => account.account.id === accountId));
|
|
18
|
+
if (step.kind === 'waiting')
|
|
19
|
+
return tiles.findIndex((tile) => tile.variant === step.variant);
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
/** The key of a tile's Connect button, so focus can come back to it. */
|
|
23
|
+
const connectKey = (platform, variant) => (variant === null ? `connect-${platform}` : `connect-${platform}-${variant}`);
|
|
10
24
|
/** Builds the class on first use, so importing the package on a server, where there is no `HTMLElement`, is safe. */
|
|
11
25
|
export function createFlungConnectClass() {
|
|
12
26
|
return class FlungConnect extends HTMLElement {
|
|
13
|
-
static observedAttributes = ['token', 'base-url', 'platforms', 'branding'];
|
|
27
|
+
static observedAttributes = ['token', 'base-url', 'platforms', 'branding', 'mode', 'layout', 'unstyled', 'variants'];
|
|
14
28
|
#root = this.attachShadow({ mode: 'open' });
|
|
29
|
+
#tooltip = panelTooltip(this.#root);
|
|
15
30
|
#source = null;
|
|
16
|
-
#client = null;
|
|
17
31
|
#labels = DEFAULT_LABELS;
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
#
|
|
22
|
-
#focus = null;
|
|
32
|
+
#controller = null;
|
|
33
|
+
#unsubscribe = null;
|
|
34
|
+
/** Each platform's step when the panel was last drawn, and the variant it was for, to move focus when one changes. */
|
|
35
|
+
#steps = new Map();
|
|
23
36
|
#started = false;
|
|
24
37
|
get token() {
|
|
25
38
|
return this.#source;
|
|
@@ -33,10 +46,22 @@ export function createFlungConnectClass() {
|
|
|
33
46
|
}
|
|
34
47
|
set labels(value) {
|
|
35
48
|
this.#labels = { ...DEFAULT_LABELS, ...value };
|
|
36
|
-
this.#
|
|
49
|
+
if (this.#controller === null)
|
|
50
|
+
this.#render();
|
|
51
|
+
else
|
|
52
|
+
this.#controller.configure({ labels: this.#labels });
|
|
53
|
+
}
|
|
54
|
+
get state() {
|
|
55
|
+
return this.#controller?.getState() ?? initialConnectState(this.#source !== null);
|
|
56
|
+
}
|
|
57
|
+
get controller() {
|
|
58
|
+
return this.#controller;
|
|
59
|
+
}
|
|
60
|
+
connect(platform, options) {
|
|
61
|
+
this.#controller?.connect(platform, options);
|
|
37
62
|
}
|
|
38
63
|
refresh() {
|
|
39
|
-
return this.#
|
|
64
|
+
return this.#controller?.refresh() ?? Promise.resolve();
|
|
40
65
|
}
|
|
41
66
|
connectedCallback() {
|
|
42
67
|
if (this.#started)
|
|
@@ -49,218 +74,209 @@ export function createFlungConnectClass() {
|
|
|
49
74
|
attributeChangedCallback(name, previous, next) {
|
|
50
75
|
if (previous === next || !this.#started)
|
|
51
76
|
return;
|
|
52
|
-
if (name === 'token')
|
|
77
|
+
if (name === 'token') {
|
|
53
78
|
this.#source = next;
|
|
54
|
-
|
|
55
|
-
|
|
79
|
+
this.#reset();
|
|
80
|
+
}
|
|
81
|
+
else if (name === 'base-url')
|
|
82
|
+
this.#reset();
|
|
56
83
|
else if (name === 'platforms')
|
|
57
|
-
|
|
84
|
+
this.#controller?.configure({ platforms: parsePlatformList(next) });
|
|
85
|
+
else if (name === 'mode')
|
|
86
|
+
this.#controller?.configure({ mode: next === 'redirect' ? 'redirect' : 'popup' });
|
|
58
87
|
else
|
|
59
|
-
this.#
|
|
88
|
+
this.#render();
|
|
60
89
|
}
|
|
90
|
+
/** A new token or API: a new controller, which reads everything again and reports a redirect that came back. */
|
|
61
91
|
#reset() {
|
|
62
92
|
if (!this.#started)
|
|
63
93
|
return;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
this.#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const granted = typeof this.#source === 'string' ? tokenPlatforms(this.#source) : null;
|
|
81
|
-
this.#platforms = platforms.map((info) => info.platform).filter((platform) => platforms.find((info) => info.platform === platform)?.connectable === true && (asked ?? [platform]).includes(platform) && (granted ?? [platform]).includes(platform));
|
|
82
|
-
this.#accounts = accounts.filter((account) => account.status !== 'disconnected' && this.#platforms.includes(account.platform));
|
|
83
|
-
if (this.#message.text === this.#labels.loading || this.#message.text === this.#labels.noToken)
|
|
84
|
-
this.#message = { text: this.#platforms.length === 0 ? this.#labels.empty : '', error: false };
|
|
85
|
-
this.#render();
|
|
86
|
-
this.#emit('flung-load', { accounts: this.#accounts });
|
|
87
|
-
}
|
|
88
|
-
catch (error) {
|
|
89
|
-
this.#fail(error, (message) => format(this.#labels.loadFailed, { message }));
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
/** A connect that left the page (`mode="redirect"`) and came back: report it, and take Flung's result off the address. */
|
|
93
|
-
#readReturn() {
|
|
94
|
-
if (typeof location === 'undefined')
|
|
95
|
-
return;
|
|
96
|
-
const result = readRedirectResult(location.href);
|
|
97
|
-
if (result === null)
|
|
98
|
-
return;
|
|
99
|
-
history.replaceState(history.state, '', withoutRedirectResult(location.href));
|
|
100
|
-
if (result.status === 'connected') {
|
|
101
|
-
const accounts = this.#accounts.filter((account) => result.accountIds.includes(account.id));
|
|
102
|
-
const platform = accounts[0]?.platform;
|
|
103
|
-
this.#say(format(this.#labels.connected, { platform: platform === undefined ? 'The account' : PLATFORM_NAMES[platform] }));
|
|
104
|
-
this.#emit('flung-connect', { session: null, accounts });
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
this.#say(result.error?.message ?? '', true);
|
|
108
|
-
this.#emit('flung-error', { code: result.error?.code ?? 'platform_error', message: result.error?.message ?? '' });
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
/** Called straight from a click or a submit: a window can only be opened during one. */
|
|
112
|
-
#start(platform, account, handle) {
|
|
113
|
-
const client = this.#client;
|
|
114
|
-
if (client === null)
|
|
115
|
-
return;
|
|
116
|
-
const redirect = this.getAttribute('mode') === 'redirect';
|
|
117
|
-
const redirectUrl = redirect ? withoutRedirectResult(location.href) : undefined;
|
|
118
|
-
const begin = () => (account === null ? client.connect(platform, { handle, redirectUrl }) : client.reconnect(account.id, { redirectUrl }));
|
|
119
|
-
const abort = new AbortController();
|
|
120
|
-
const row = { kind: 'waiting', abort, blockedUrl: null };
|
|
121
|
-
this.#rows.set(platform, row);
|
|
122
|
-
this.#say('');
|
|
123
|
-
if (redirect) {
|
|
124
|
-
void begin().then((session) => session.authorizationUrl !== null && location.assign(session.authorizationUrl), (error) => this.#settle(platform, error));
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
void connectInPopup(client, begin, { signal: abort.signal })
|
|
128
|
-
.then(async (outcome) => {
|
|
129
|
-
if (outcome.kind === 'finished')
|
|
130
|
-
return outcome.session;
|
|
131
|
-
row.blockedUrl = outcome.session.authorizationUrl;
|
|
132
|
-
this.#render();
|
|
133
|
-
return client.waitForSession(outcome.session.id, { signal: abort.signal });
|
|
134
|
-
})
|
|
135
|
-
.then((session) => this.#finish(platform, session), (error) => this.#settle(platform, error));
|
|
136
|
-
}
|
|
137
|
-
async #finish(platform, session) {
|
|
138
|
-
this.#rows.set(platform, { kind: 'idle' });
|
|
139
|
-
const name = PLATFORM_NAMES[platform];
|
|
140
|
-
if (session.status === 'connected') {
|
|
141
|
-
await this.#load();
|
|
142
|
-
this.#say(format(this.#labels.connected, { platform: name }));
|
|
143
|
-
this.#emit('flung-connect', { session, accounts: this.#accounts.filter((account) => session.accountIds.includes(account.id)) });
|
|
144
|
-
}
|
|
145
|
-
else if (session.status === 'expired') {
|
|
146
|
-
this.#say(format(this.#labels.expired, { platform: name }), true);
|
|
147
|
-
this.#emit('flung-error', { code: 'expired', message: this.#message.text });
|
|
148
|
-
}
|
|
149
|
-
else {
|
|
150
|
-
const message = session.error?.message ?? '';
|
|
151
|
-
this.#say(format(this.#labels.failed, { platform: name, message }), true);
|
|
152
|
-
this.#emit('flung-error', { code: session.error?.code ?? 'platform_error', message });
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
/** A connect that stopped early: cancelled by the person, or refused by the API. */
|
|
156
|
-
#settle(platform, error) {
|
|
157
|
-
this.#rows.set(platform, { kind: 'idle' });
|
|
158
|
-
if (error instanceof DOMException && error.name === 'AbortError')
|
|
159
|
-
return this.#render();
|
|
160
|
-
this.#fail(error, (message) => format(this.#labels.failed, { platform: PLATFORM_NAMES[platform], message }));
|
|
161
|
-
}
|
|
162
|
-
async #disconnect(account) {
|
|
163
|
-
this.#rows.set(account.platform, { kind: 'idle' });
|
|
164
|
-
try {
|
|
165
|
-
const disconnected = await this.#client.disconnect(account.id);
|
|
166
|
-
await this.#load();
|
|
167
|
-
this.#say(format(this.#labels.disconnected, { account: displayName(account) }));
|
|
168
|
-
this.#emit('flung-disconnect', { account: disconnected });
|
|
169
|
-
}
|
|
170
|
-
catch (error) {
|
|
171
|
-
this.#fail(error, (message) => message);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
#fail(error, say) {
|
|
175
|
-
const detail = error instanceof FlungConnectError ? { code: error.code, message: error.message } : { code: 'unknown', message: String(error) };
|
|
176
|
-
this.#say(say(detail.message), true);
|
|
177
|
-
this.#emit('flung-error', detail);
|
|
178
|
-
}
|
|
179
|
-
#say(text, error = false) {
|
|
180
|
-
this.#message = { text, error };
|
|
94
|
+
this.#unsubscribe?.();
|
|
95
|
+
this.#controller?.destroy();
|
|
96
|
+
this.#steps.clear();
|
|
97
|
+
const controller = createConnectController({
|
|
98
|
+
token: this.#source,
|
|
99
|
+
baseUrl: this.getAttribute('base-url') ?? undefined,
|
|
100
|
+
platforms: parsePlatformList(this.getAttribute('platforms')),
|
|
101
|
+
mode: this.getAttribute('mode') === 'redirect' ? 'redirect' : 'popup',
|
|
102
|
+
labels: this.#labels,
|
|
103
|
+
onLoad: (detail) => this.#emit('flung-load', detail),
|
|
104
|
+
onConnect: (detail) => this.#emit('flung-connect', detail),
|
|
105
|
+
onDisconnect: (detail) => this.#emit('flung-disconnect', detail),
|
|
106
|
+
onError: (detail) => this.#emit('flung-error', detail),
|
|
107
|
+
});
|
|
108
|
+
this.#controller = controller;
|
|
109
|
+
this.#unsubscribe = controller.subscribe(() => this.#render());
|
|
181
110
|
this.#render();
|
|
182
111
|
}
|
|
183
112
|
#emit(type, detail) {
|
|
184
113
|
this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
|
|
185
114
|
}
|
|
186
115
|
#render() {
|
|
116
|
+
const state = this.state;
|
|
117
|
+
const labels = state.labels;
|
|
118
|
+
const layout = LAYOUTS.find((value) => value === this.getAttribute('layout')) ?? 'list';
|
|
187
119
|
const active = this.#root.activeElement instanceof HTMLElement ? (this.#root.activeElement.dataset['key'] ?? null) : null;
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
120
|
+
const tiles = this.getAttribute('variants') === 'tiles';
|
|
121
|
+
const list = html('ul', { class: 'list', part: 'list' }, ...state.platforms.flatMap((item) => this.#platformRows(item, labels, tiles)));
|
|
122
|
+
const empty = state.message.text === '';
|
|
123
|
+
const message = html('p', { class: state.message.error ? 'message error' : 'message', part: state.message.error ? 'message message-error' : 'message', role: 'status', 'aria-live': 'polite', hidden: empty }, state.message.text);
|
|
124
|
+
const panel = html('section', { class: 'panel', part: 'panel', 'data-layout': layout, 'aria-label': labels.panel }, list, message, this.#footer(labels));
|
|
125
|
+
const styles = this.hasAttribute('unstyled') ? BASE_STYLES + TOOLTIP_BASE_STYLES : STYLES + TOOLTIP_BASE_STYLES + TOOLTIP_STYLES;
|
|
126
|
+
// The element a tooltip points at is about to be replaced, so the tooltip goes with it.
|
|
127
|
+
this.#tooltip.hide();
|
|
128
|
+
this.#root.replaceChildren(html('style', {}, styles), panel, this.#tooltip.element);
|
|
129
|
+
this.#placeFocus(state, active);
|
|
130
|
+
this.dispatchEvent(new CustomEvent('flung-change', { detail: { state } }));
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Keeps focus where the person is: on the field when a question opens, on the first choice when the platform asks
|
|
134
|
+
* what to connect, on Keep when a disconnect asks to confirm, and back on Connect when a question or a wait closes.
|
|
135
|
+
* Otherwise on whatever had it before the panel was drawn again.
|
|
136
|
+
*/
|
|
137
|
+
#placeFocus(state, active) {
|
|
138
|
+
let wanted = active;
|
|
139
|
+
const fallback = [];
|
|
140
|
+
for (const item of state.platforms) {
|
|
141
|
+
const before = this.#steps.get(item.platform) ?? { kind: 'idle', variant: null };
|
|
142
|
+
const now = item.step.kind;
|
|
143
|
+
if (now === before.kind)
|
|
144
|
+
continue;
|
|
145
|
+
if (now === 'handle')
|
|
146
|
+
wanted = `handle-${item.platform}`;
|
|
147
|
+
else if (now === 'choose')
|
|
148
|
+
wanted = `choose-${item.platform}-personal`;
|
|
149
|
+
else if (now === 'confirm')
|
|
150
|
+
wanted = `keep-${item.platform}`;
|
|
151
|
+
// Back to the Connect that started it: a variant's own tile, else the platform's.
|
|
152
|
+
else if (now === 'idle')
|
|
153
|
+
fallback.push(connectKey(item.platform, before.variant), connectKey(item.platform, null));
|
|
154
|
+
}
|
|
155
|
+
this.#steps = new Map(state.platforms.map((item) => [item.platform, { kind: item.step.kind, variant: item.step.kind === 'waiting' ? item.step.variant : null }]));
|
|
156
|
+
const find = (key) => (key === null ? undefined : [...this.#root.querySelectorAll('[data-key]')].find((node) => node.dataset['key'] === key));
|
|
157
|
+
(find(wanted) ?? (active !== null ? fallback.map(find).find((node) => node !== undefined) : undefined))?.focus();
|
|
196
158
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
159
|
+
/**
|
|
160
|
+
* A platform's rows: one, or with `variants="tiles"` one per way it connects (LinkedIn, then LinkedIn Page), each with
|
|
161
|
+
* its own accounts. A platform that connects only through a variant is drawn as that variant's tile.
|
|
162
|
+
*/
|
|
163
|
+
#platformRows(item, labels, tiles) {
|
|
164
|
+
const slot = `platform-${item.platform}`;
|
|
165
|
+
if (item.variants.length === 0)
|
|
166
|
+
return [this.#platformRow(item, { variant: null, accounts: item.accounts }, labels, { chooser: false, extra: true, slot })];
|
|
167
|
+
if (!tiles) {
|
|
168
|
+
const variant = item.connectable ? null : (item.variants[0] ?? null);
|
|
169
|
+
return [this.#platformRow(item, { variant, accounts: item.accounts }, labels, { chooser: item.connectable, extra: true, slot })];
|
|
170
|
+
}
|
|
171
|
+
const own = item.connectable ? [{ variant: null, accounts: item.accounts.filter((account) => !account.page) }] : [];
|
|
172
|
+
const split = [...own, ...item.variants.map((variant) => ({ variant, accounts: item.accounts.filter((account) => account.page) }))];
|
|
173
|
+
const at = Math.max(0, stepTile(item, split));
|
|
174
|
+
return split.map((tile, index) => this.#platformRow(item, tile, labels, { chooser: false, extra: index === at, slot: index === 0 ? `platform-${item.platform}` : `platform-${item.platform}-${tile.variant ?? ''}` }));
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* One tile: mark, name, Connect, and its accounts. `chooser` makes Connect ask what to connect; `extra` puts the
|
|
178
|
+
* platform's question, wait or confirm under it; `slot` names the slot a page's own row goes in.
|
|
179
|
+
*/
|
|
180
|
+
#platformRow(item, tile, labels, options) {
|
|
181
|
+
const { chooser } = options;
|
|
182
|
+
const { platform, busy, step } = item;
|
|
183
|
+
const { variant } = tile;
|
|
184
|
+
const name = variant === null ? item.name : format(labels.pagesName, { platform: item.name });
|
|
185
|
+
const first = tile.accounts.length === 0;
|
|
186
|
+
const text = first ? labels.connect : labels.connectAnother;
|
|
187
|
+
const choosing = chooser && step.kind === 'choose';
|
|
188
|
+
// With a chooser, Connect opens the question and closes it again; a tile connects its own way straight away.
|
|
189
|
+
const onConnect = () => (choosing ? this.#controller?.cancel(platform) : this.#controller?.connect(platform, chooser ? {} : { variant }));
|
|
190
|
+
const connect = button(text, connectKey(platform, variant), 'connect', onConnect, {
|
|
191
|
+
variant: first ? 'primary' : 'secondary',
|
|
192
|
+
disabled: busy,
|
|
193
|
+
label: `${text} ${name}`,
|
|
194
|
+
...(chooser ? { expanded: choosing, controls: `choose-${platform}` } : {}),
|
|
195
|
+
});
|
|
196
|
+
const count = first ? labels.notConnected : format(labels.connectedCount, { count: String(tile.accounts.length) });
|
|
197
|
+
const title = html('span', { class: 'title', part: 'title' }, html('span', { class: 'name', part: 'name' }, name), html('span', { class: first ? 'count' : 'count on', part: first ? 'count' : 'count count-connected' }, count));
|
|
198
|
+
const badge = html('span', { class: 'badge', part: 'badge', 'aria-hidden': 'true' }, mark(PLATFORM_MARKS[platform].path));
|
|
199
|
+
const row = html('div', { class: 'row', part: 'row' }, html('slot', { name: options.slot }, badge, title, connect));
|
|
200
|
+
const items = tile.accounts.map((account) => this.#accountRow(account, busy, labels));
|
|
201
|
+
const parts = variant === null ? `platform platform-${platform}` : `platform platform-${platform} platform-${platform}-${variant}`;
|
|
202
|
+
return html('li', { class: 'platform', part: parts, 'data-platform': platform, 'data-variant': variant ?? undefined }, row, items.length === 0 ? null : html('ul', { class: 'accounts', part: 'accounts' }, ...items), options.extra ? this.#rowExtra(item, labels) : null);
|
|
209
203
|
}
|
|
210
|
-
#accountRow(
|
|
211
|
-
const
|
|
212
|
-
const status =
|
|
213
|
-
const reconnect = status === null ? null : button(
|
|
214
|
-
const disconnect = button(
|
|
215
|
-
|
|
204
|
+
#accountRow(item, busy, labels) {
|
|
205
|
+
const { account } = item;
|
|
206
|
+
const status = item.reconnect === null ? null : item.reconnect.reason === 'needs_reconnect' ? labels.needsReconnect : format(labels.reconnectBy, { date: new Date(item.reconnect.by).toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) });
|
|
207
|
+
const reconnect = status === null ? null : button(labels.reconnect, `reconnect-${account.id}`, 'reconnect', () => this.#controller?.reconnect(account.id), { disabled: busy, label: `${labels.reconnect} ${item.name}` });
|
|
208
|
+
const disconnect = button(labels.disconnect, `disconnect-${account.id}`, 'disconnect', () => this.#controller?.requestDisconnect(account.id), { variant: 'quiet', disabled: busy, label: `${labels.disconnect} ${item.name}` });
|
|
209
|
+
const names = html('span', { class: 'names', part: 'account-names' }, html('span', { class: 'handle', part: 'account-name', 'data-tooltip': item.displayName, 'data-tooltip-truncated': true }, item.displayName), item.username === null ? null : html('span', { class: 'username', part: 'account-username', 'data-tooltip': `@${item.username}`, 'data-tooltip-truncated': true }, `@${item.username}`));
|
|
210
|
+
const tag = item.page ? html('span', { class: 'tag', part: 'tag tag-page' }, labels.pageTag) : null;
|
|
211
|
+
const who = html('span', { class: 'who', part: 'account-details' }, names, tag, status === null ? null : html('span', { class: 'status', part: 'status' }, status));
|
|
212
|
+
return html('li', { class: 'account', part: 'account', 'data-account': account.id }, html('slot', { name: `account-${account.id}` }, avatar(item), who, html('span', { class: 'actions', part: 'actions' }, reconnect, disconnect)));
|
|
216
213
|
}
|
|
217
214
|
/** Flung's mark and name, linking to its docs, unless the page set `branding="none"`. */
|
|
218
|
-
#footer() {
|
|
215
|
+
#footer(labels) {
|
|
219
216
|
if (this.getAttribute('branding') === 'none')
|
|
220
217
|
return null;
|
|
221
|
-
const link = html('a', { href: 'https://docs.flung.dev', target: '_blank', rel: 'noopener' }, mark(FLUNG_MARK.path, FLUNG_MARK.viewBox, 'flung'), 'Flung');
|
|
222
|
-
return html('p', { class: 'footer', part: 'footer' },
|
|
218
|
+
const link = html('a', { href: 'https://docs.flung.dev', target: '_blank', rel: 'noopener', part: 'footer-link' }, mark(FLUNG_MARK.path, { viewBox: FLUNG_MARK.viewBox, className: 'flung', part: 'footer-mark', size: 14 }), 'Flung');
|
|
219
|
+
return html('p', { class: 'footer', part: 'footer' }, labels.securedBy, link);
|
|
223
220
|
}
|
|
224
|
-
#rowExtra(
|
|
225
|
-
const name =
|
|
226
|
-
if (
|
|
221
|
+
#rowExtra(item, labels) {
|
|
222
|
+
const { platform, name, step } = item;
|
|
223
|
+
if (step.kind === 'handle') {
|
|
227
224
|
const id = `handle-${platform}`;
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
const form = html('form', { class: 'form', part: 'form' }, html('label', { for: id }, platform === 'mastodon' ?
|
|
225
|
+
const input = html('input', { id, name: 'handle', part: 'input', 'data-key': id, autocomplete: 'username', spellcheck: 'false', required: step.required });
|
|
226
|
+
const submit = html('button', { type: 'submit', class: 'primary', part: 'button button-primary button-continue', 'data-key': `continue-${platform}` }, labels.continue);
|
|
227
|
+
const form = html('form', { class: 'form', part: 'form' }, html('label', { for: id, part: 'label' }, platform === 'mastodon' ? labels.handleMastodon : labels.handleBluesky), input, submit, button(labels.cancel, `cancel-${platform}`, 'cancel', () => this.#controller?.cancel(platform)));
|
|
231
228
|
form.addEventListener('submit', (event) => {
|
|
232
229
|
event.preventDefault();
|
|
233
|
-
|
|
234
|
-
this.#start(platform, null, handle === '' ? undefined : handle);
|
|
230
|
+
this.#controller?.submitHandle(platform, input.value);
|
|
235
231
|
});
|
|
236
232
|
return form;
|
|
237
233
|
}
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
234
|
+
if (step.kind === 'choose')
|
|
235
|
+
return this.#chooser(item, step.variants, labels);
|
|
236
|
+
if (step.kind === 'waiting') {
|
|
237
|
+
const link = step.blockedUrl === null ? null : html('a', { href: step.blockedUrl, target: '_blank', rel: 'noopener', part: 'link' }, format(labels.openSignIn, { platform: name }));
|
|
238
|
+
const text = step.blockedUrl === null ? format(labels.waiting, { platform: name }) : `${format(labels.blocked, { platform: name })} `;
|
|
239
|
+
return html('p', { class: 'note', part: 'note' }, html('span', { class: 'spinner', part: 'spinner', 'aria-hidden': 'true' }), html('span', { class: 'note-text', part: 'note-text' }, text, link), button(labels.cancel, `cancel-${platform}`, 'cancel', () => this.#controller?.cancel(platform)));
|
|
242
240
|
}
|
|
243
|
-
if (
|
|
244
|
-
return html('div', { class: 'confirm', part: 'confirm' }, html('p', {}, format(
|
|
241
|
+
if (step.kind === 'confirm') {
|
|
242
|
+
return html('div', { class: 'confirm', part: 'confirm' }, html('p', { part: 'confirm-text' }, format(labels.confirmDisconnect, { account: step.account.name })), button(labels.disconnect, `confirm-${platform}`, 'confirm', () => void this.#controller?.confirmDisconnect(platform), { variant: 'danger' }), button(labels.keep, `keep-${platform}`, 'keep', () => this.#controller?.cancel(platform)));
|
|
245
243
|
}
|
|
246
244
|
return null;
|
|
247
245
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
246
|
+
/**
|
|
247
|
+
* What to connect, when a platform offers its own connect and a variant: a group of two buttons, each with an icon,
|
|
248
|
+
* a name and a line under it (read as the button's description). Arrow keys move between them, Escape closes the
|
|
249
|
+
* question, and focus starts on the first.
|
|
250
|
+
*/
|
|
251
|
+
#chooser(item, variants, labels) {
|
|
252
|
+
const { platform } = item;
|
|
253
|
+
const titleId = `choose-title-${platform}`;
|
|
254
|
+
const option = (variant, key, icon, name, description) => {
|
|
255
|
+
const nameId = `${key}-name`;
|
|
256
|
+
const descriptionId = `${key}-description`;
|
|
257
|
+
const element = html('button', { type: 'button', class: 'option', part: `button button-option button-option-${variant ?? 'personal'}`, 'data-key': key, 'aria-labelledby': nameId, 'aria-describedby': descriptionId }, html('span', { class: 'option-icon', part: 'option-icon', 'aria-hidden': 'true' }, mark(icon, { className: 'option-mark', part: 'option-mark', size: 18 })), html('span', { class: 'option-text', part: 'option-text' }, html('span', { class: 'option-name', part: 'option-name', id: nameId }, name), html('span', { class: 'option-description', part: 'option-description', id: descriptionId }, description)));
|
|
258
|
+
element.addEventListener('click', () => this.#controller?.connect(platform, { variant }));
|
|
259
|
+
return element;
|
|
260
|
+
};
|
|
261
|
+
const options = [
|
|
262
|
+
option(null, `choose-${platform}-personal`, CHOICE_MARKS.personal, labels.variantPersonal, labels.variantPersonalDescription),
|
|
263
|
+
...variants.map((variant) => option(variant, `choose-${platform}-${variant}`, CHOICE_MARKS[variant], labels.variantPages, labels.variantPagesDescription)),
|
|
264
|
+
];
|
|
265
|
+
const group = html('div', { class: 'choose', part: 'choose', id: `choose-${platform}`, role: 'group', 'aria-labelledby': titleId }, html('p', { class: 'choose-title', part: 'choose-title', id: titleId }, format(labels.chooseVariant, { platform: item.name })), html('div', { class: 'options', part: 'choose-options' }, ...options), button(labels.cancel, `cancel-${platform}`, 'cancel', () => this.#controller?.cancel(platform)));
|
|
266
|
+
group.addEventListener('keydown', (event) => {
|
|
267
|
+
if (event.key === 'Escape') {
|
|
268
|
+
event.preventDefault();
|
|
269
|
+
this.#controller?.cancel(platform);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key];
|
|
273
|
+
const at = options.indexOf(event.target);
|
|
274
|
+
if (step === undefined || at === -1)
|
|
275
|
+
return;
|
|
276
|
+
event.preventDefault();
|
|
277
|
+
options[(at + step + options.length) % options.length]?.focus();
|
|
278
|
+
});
|
|
279
|
+
return group;
|
|
264
280
|
}
|
|
265
281
|
};
|
|
266
282
|
}
|
package/dist/element/marks.d.ts
CHANGED
|
@@ -5,8 +5,13 @@ export interface PlatformMark {
|
|
|
5
5
|
readonly hex: string;
|
|
6
6
|
}
|
|
7
7
|
export declare const PLATFORM_MARKS: Readonly<Record<Platform, PlatformMark>>;
|
|
8
|
+
/** The icons of the choice between a personal profile and a company page, on a 24 by 24 grid (Boxicons, MIT). */
|
|
9
|
+
export declare const CHOICE_MARKS: {
|
|
10
|
+
readonly personal: 'M12 2a5 5 0 1 0 5 5 5 5 0 0 0-5-5zm0 8a3 3 0 1 1 3-3 3 3 0 0 1-3 3zm9 11v-1a7 7 0 0 0-7-7h-4a7 7 0 0 0-7 7v1h2v-1a5 5 0 0 1 5-5h4a5 5 0 0 1 5 5v1z';
|
|
11
|
+
readonly pages: 'M19 2H9c-1.103 0-2 .897-2 2v6H5c-1.103 0-2 .897-2 2v9a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4c0-1.103-.897-2-2-2zM5 12h6v8H5v-8zm14 8h-6v-8c0-1.103-.897-2-2-2H9V4h10v16zM11 6h2v2h-2zm4 0h2v2h-2zm0 4.031h2V12h-2zM15 14h2v2h-2zm-8 .001h2v2H7z';
|
|
12
|
+
};
|
|
8
13
|
/** Flung's mark, from https://assets.flung.dev/brand/logo/flung-mark-on-light.svg. */
|
|
9
14
|
export declare const FLUNG_MARK: {
|
|
10
|
-
readonly viewBox: '
|
|
11
|
-
readonly path: '
|
|
15
|
+
readonly viewBox: '-2 -2 36 36';
|
|
16
|
+
readonly path: 'M12.942 6.235L21.674 4.058A6.0 6.0 0 0 1 28.947 8.428L29.915 12.309A6.0 6.0 0 0 1 25.545 19.582L16.812 21.76A6.0 6.0 0 0 1 9.539 17.39L8.571 13.508A6.0 6.0 0 0 1 12.942 6.235ZM13.758 19.429L20.55 17.736L14.528 29.131Z M3 12.2H7.4 M2.2 18H5.4';
|
|
12
17
|
};
|
package/dist/element/marks.js
CHANGED
|
@@ -24,5 +24,12 @@ export const PLATFORM_MARKS = {
|
|
|
24
24
|
// Simple Icons farcaster (CC0 1.0)
|
|
25
25
|
farcaster: { hex: '855DCD', path: 'M18.24.24H5.76C2.5789.24 0 2.8188 0 6v12c0 3.1811 2.5789 5.76 5.76 5.76h12.48c3.1812 0 5.76-2.5789 5.76-5.76V6C24 2.8188 21.4212.24 18.24.24m.8155 17.1662v.504c.2868-.0256.5458.1905.5439.479v.5688h-5.1437v-.5688c-.0019-.2885.2576-.5047.5443-.479v-.504c0-.22.1525-.402.358-.458l-.0095-4.3645c-.1589-1.7366-1.6402-3.0979-3.4435-3.0979-1.8038 0-3.2846 1.3613-3.4435 3.0979l-.0096 4.3578c.2276.0424.5318.2083.5395.4648v.504c.2863-.0256.5457.1905.5438.479v.5688H4.3915v-.5688c-.0019-.2885.2575-.5047.5438-.479v-.504c0-.2529.2011-.4548.4536-.4724v-7.895h-.4905L4.2898 7.008l2.6405-.0005V5.0419h9.9495v1.9656h2.8219l-.6091 2.0314h-.4901v7.8949c.2519.0177.453.2195.453.4724' },
|
|
26
26
|
};
|
|
27
|
+
/** The icons of the choice between a personal profile and a company page, on a 24 by 24 grid (Boxicons, MIT). */
|
|
28
|
+
export const CHOICE_MARKS = {
|
|
29
|
+
// Boxicons bx-user
|
|
30
|
+
personal: 'M12 2a5 5 0 1 0 5 5 5 5 0 0 0-5-5zm0 8a3 3 0 1 1 3-3 3 3 0 0 1-3 3zm9 11v-1a7 7 0 0 0-7-7h-4a7 7 0 0 0-7 7v1h2v-1a5 5 0 0 1 5-5h4a5 5 0 0 1 5 5v1z',
|
|
31
|
+
// Boxicons bx-buildings
|
|
32
|
+
pages: 'M19 2H9c-1.103 0-2 .897-2 2v6H5c-1.103 0-2 .897-2 2v9a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4c0-1.103-.897-2-2-2zM5 12h6v8H5v-8zm14 8h-6v-8c0-1.103-.897-2-2-2H9V4h10v16zM11 6h2v2h-2zm4 0h2v2h-2zm0 4.031h2V12h-2zM15 14h2v2h-2zm-8 .001h2v2H7z',
|
|
33
|
+
};
|
|
27
34
|
/** Flung's mark, from https://assets.flung.dev/brand/logo/flung-mark-on-light.svg. */
|
|
28
|
-
export const FLUNG_MARK = { viewBox: '
|
|
35
|
+
export const FLUNG_MARK = { viewBox: '-2 -2 36 36', path: 'M12.942 6.235L21.674 4.058A6.0 6.0 0 0 1 28.947 8.428L29.915 12.309A6.0 6.0 0 0 1 25.545 19.582L16.812 21.76A6.0 6.0 0 0 1 9.539 17.39L8.571 13.508A6.0 6.0 0 0 1 12.942 6.235ZM13.758 19.429L20.55 17.736L14.528 29.131Z M3 12.2H7.4 M2.2 18H5.4' };
|
package/dist/element/styles.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
/** What `unstyled` keeps: the element is a block, and `hidden` hides it. Everything else is the page's, through `::part()`. */
|
|
2
|
+
export declare const BASE_STYLES = "\n:host { display: block; }\n:host([hidden]) { display: none; }\n[hidden] { display: none !important; }\n";
|
|
1
3
|
/**
|
|
2
|
-
* The panel's look, inside its shadow root.
|
|
3
|
-
* page can set on the element; each
|
|
4
|
-
* unless `theme` says which.
|
|
4
|
+
* The panel's look, inside its shadow root. Colours, type, spacing, sizes, radii, borders, shadows and the focus ring
|
|
5
|
+
* are `--flung-*` properties a page can set on the element; each piece has a `part` name for `::part()`. Follows the
|
|
6
|
+
* page's light or dark scheme unless `theme` says which. `layout` picks `list`, `grid` or `compact`.
|
|
5
7
|
*/
|
|
6
8
|
export declare const STYLES: string;
|