@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.
@@ -1,25 +1,38 @@
1
- import { ConnectClient, FlungConnectError } from '../core/client.js';
2
- import { connectInPopup, readRedirectResult, withoutRedirectResult } from '../core/flows.js';
3
- import { HANDLE_PROMPT, parsePlatformList, PLATFORM_NAMES, tokenPlatforms } from '../core/platforms.js';
4
- import { DEFAULT_LABELS, format } from './copy.js';
5
- import { button, displayName, html, mark } from './dom.js';
6
- import { FLUNG_MARK, PLATFORM_MARKS } from './marks.js';
7
- import { STYLES } from './styles.js';
8
- /** How soon a reconnect deadline is worth showing. */
9
- const SOON_MS = 14 * 86_400_000;
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
- #platforms = [];
19
- #accounts = [];
20
- #rows = new Map();
21
- #message = { text: '', error: false };
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.#render();
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.#load();
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
- if (name === 'branding')
55
- this.#render();
79
+ this.#reset();
80
+ }
81
+ else if (name === 'base-url')
82
+ this.#reset();
56
83
  else if (name === 'platforms')
57
- void this.#load();
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.#reset();
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
- for (const row of this.#rows.values())
65
- if (row.kind === 'waiting')
66
- row.abort.abort();
67
- this.#rows.clear();
68
- this.#client = this.#source === null ? null : new ConnectClient({ token: this.#source, baseUrl: this.getAttribute('base-url') ?? undefined });
69
- void this.#load().then(() => this.#readReturn());
70
- }
71
- async #load() {
72
- const client = this.#client;
73
- if (client === null)
74
- return this.#say(this.#labels.noToken);
75
- if (this.#platforms.length === 0)
76
- this.#say(this.#labels.loading);
77
- try {
78
- const [platforms, accounts] = await Promise.all([client.platforms(), client.accounts()]);
79
- const asked = parsePlatformList(this.getAttribute('platforms'));
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 list = html('ul', { class: 'list', part: 'list' }, ...this.#platforms.map((platform) => this.#platformRow(platform)));
189
- const message = html('p', { class: this.#message.error ? 'message error' : 'message', part: 'message', role: 'status', 'aria-live': 'polite' }, this.#message.text);
190
- const panel = html('section', { class: 'panel', part: 'panel', 'aria-label': this.#labels.panel }, list, message, this.#footer());
191
- this.#root.replaceChildren(html('style', {}, STYLES), panel);
192
- const key = this.#focus ?? active;
193
- this.#focus = null;
194
- if (key !== null)
195
- this.#root.querySelector(`[data-key="${key}"]`)?.focus();
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
- #platformRow(platform) {
198
- const name = PLATFORM_NAMES[platform];
199
- const state = this.#rows.get(platform) ?? { kind: 'idle' };
200
- const busy = state.kind === 'waiting';
201
- const accounts = this.#accounts.filter((account) => account.platform === platform);
202
- const first = accounts.length === 0;
203
- const connect = button(first ? this.#labels.connect : this.#labels.connectAnother, `connect-${platform}`, () => this.#askOrStart(platform), { primary: first, disabled: busy, label: `${first ? this.#labels.connect : this.#labels.connectAnother} ${name}` });
204
- const count = first ? this.#labels.notConnected : format(this.#labels.connectedCount, { count: String(accounts.length) });
205
- const title = html('span', { class: 'title' }, html('span', { class: 'name', part: 'name' }, name), html('span', { class: first ? 'count' : 'count on' }, count));
206
- const row = html('div', { class: 'row' }, html('span', { class: 'badge', part: 'badge', 'aria-hidden': 'true' }, mark(PLATFORM_MARKS[platform].path)), title, connect);
207
- const items = accounts.map((account) => this.#accountRow(account, busy));
208
- return html('li', { class: 'platform', part: 'platform', 'data-platform': platform }, row, items.length === 0 ? null : html('ul', { class: 'accounts' }, ...items), this.#rowExtra(platform, state));
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(account, busy) {
211
- const due = account.reconnectBy === null ? null : new Date(account.reconnectBy);
212
- const status = account.status === 'needs_reconnect' ? this.#labels.needsReconnect : due !== null && due.getTime() - Date.now() < SOON_MS ? format(this.#labels.reconnectBy, { date: due.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) }) : null;
213
- const reconnect = status === null ? null : button(this.#labels.reconnect, `reconnect-${account.id}`, () => this.#start(account.platform, account, undefined), { warning: true, disabled: busy });
214
- const disconnect = button(this.#labels.disconnect, `disconnect-${account.id}`, () => this.#confirm(account), { quiet: true, disabled: busy, label: `${this.#labels.disconnect} ${displayName(account)}` });
215
- return html('li', { class: 'account', part: 'account', 'data-account': account.id }, html('span', { class: 'who' }, html('span', { class: 'handle', title: displayName(account) }, displayName(account)), status === null ? null : html('span', { class: 'status', part: 'status' }, status)), html('span', { class: 'actions' }, reconnect, disconnect));
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' }, this.#labels.securedBy, link);
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(platform, state) {
225
- const name = PLATFORM_NAMES[platform];
226
- if (state.kind === 'handle') {
221
+ #rowExtra(item, labels) {
222
+ const { platform, name, step } = item;
223
+ if (step.kind === 'handle') {
227
224
  const id = `handle-${platform}`;
228
- const required = HANDLE_PROMPT[platform]?.required === true;
229
- const input = html('input', { id, name: 'handle', 'data-key': id, autocomplete: 'username', spellcheck: 'false', required });
230
- const form = html('form', { class: 'form', part: 'form' }, html('label', { for: id }, platform === 'mastodon' ? this.#labels.handleMastodon : this.#labels.handleBluesky), input, html('button', { type: 'submit', class: 'primary', 'data-key': `continue-${platform}` }, this.#labels.continue), button(this.#labels.cancel, `cancel-${platform}`, () => this.#idle(platform)));
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
- const handle = input.value.trim();
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 (state.kind === 'waiting') {
239
- const link = state.blockedUrl === null ? null : html('a', { href: state.blockedUrl, target: '_blank', rel: 'noopener' }, format(this.#labels.openSignIn, { platform: name }));
240
- const text = state.blockedUrl === null ? format(this.#labels.waiting, { platform: name }) : `${format(this.#labels.blocked, { platform: name })} `;
241
- return html('p', { class: 'note', part: 'note' }, html('span', { class: 'spinner', 'aria-hidden': 'true' }), html('span', { class: 'note-text' }, text, link), button(this.#labels.cancel, `cancel-${platform}`, () => (state.abort.abort(), this.#idle(platform))));
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 (state.kind === 'confirm') {
244
- return html('div', { class: 'confirm', part: 'confirm' }, html('p', {}, format(this.#labels.confirmDisconnect, { account: displayName(state.account) })), button(this.#labels.disconnect, `confirm-${platform}`, () => void this.#disconnect(state.account), { danger: true }), button(this.#labels.keep, `keep-${platform}`, () => this.#idle(platform)));
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
- #askOrStart(platform) {
249
- if (HANDLE_PROMPT[platform] === undefined)
250
- return this.#start(platform, null, undefined);
251
- this.#rows.set(platform, { kind: 'handle' });
252
- this.#focus = `handle-${platform}`;
253
- this.#render();
254
- }
255
- #confirm(account) {
256
- this.#rows.set(account.platform, { kind: 'confirm', account });
257
- this.#focus = `keep-${account.platform}`;
258
- this.#render();
259
- }
260
- #idle(platform) {
261
- this.#rows.set(platform, { kind: 'idle' });
262
- this.#focus = `connect-${platform}`;
263
- this.#render();
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
  }
@@ -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: '0 0 500 500';
11
- readonly path: 'M 189.525 16.874 C 190.808 16.873 192.091 16.873 193.413 16.872 C 196.941 16.869 200.467 16.873 203.992 16.878 C 207.81 16.884 211.625 16.881 215.442 16.881 C 222.042 16.88 228.642 16.884 235.242 16.891 C 244.784 16.899 254.326 16.902 263.868 16.904 C 279.352 16.906 294.837 16.913 310.322 16.924 C 325.357 16.933 340.393 16.941 355.428 16.947 C 356.82 16.947 356.82 16.947 358.24 16.947 C 362.896 16.949 367.55 16.95 372.206 16.951 C 410.783 16.963 449.359 16.982 487.935 17.007 C 487.954 19.653 487.967 22.299 487.978 24.945 C 487.986 26.06 487.986 26.06 487.994 27.198 C 488.011 33.049 487.504 38.516 486.278 44.226 C 485.989 45.603 485.989 45.603 485.694 47.009 C 479.753 73.338 467.128 96.459 449.249 116.488 C 448.329 117.521 447.408 118.554 446.46 119.619 C 434.313 132.43 419.889 141.304 403.93 148.543 C 402.995 148.995 402.061 149.447 401.097 149.912 C 387.969 155.816 373.086 157.455 358.886 158.492 C 337.903 160.08 318.12 166.235 302.238 180.598 C 301.228 181.476 301.228 181.476 300.196 182.373 C 290.186 191.634 284.215 203.986 280.409 216.88 C 276.039 231.615 269.265 243.914 255.779 252.324 C 245.106 257.877 235.96 259.309 224.105 259.493 C 207.259 259.997 192.415 263.213 180.305 275.865 C 171.548 286.39 167.399 299.632 168.491 313.239 C 169.76 324.473 175.361 336.705 183.966 344.189 C 197.7 354.156 210.093 357.822 227.074 356.348 C 240.814 353.699 252.153 345.546 260.235 334.242 C 264.938 325.96 267.358 317.705 268.395 308.295 C 270.357 290.773 276.223 278.048 289.803 266.539 C 299.876 258.871 311.257 253.264 324.085 253.285 C 324.795 253.283 325.507 253.282 326.238 253.28 C 328.597 253.276 330.956 253.287 333.315 253.3 C 335.015 253.301 336.714 253.301 338.415 253.3 C 343.014 253.298 347.612 253.311 352.211 253.327 C 357.024 253.34 361.836 253.341 366.649 253.344 C 375.754 253.351 384.859 253.369 393.963 253.391 C 404.333 253.416 414.703 253.429 425.071 253.439 C 446.395 253.463 467.718 253.502 489.041 253.551 C 489.12 263.833 489.178 274.116 489.213 284.399 C 489.231 289.177 489.253 293.955 489.292 298.732 C 489.713 353.119 479.51 398.095 440.406 438.144 C 402.556 475.574 354.507 484.025 303.516 483.908 C 298.647 483.9 293.777 483.908 288.908 483.913 C 280.739 483.918 272.57 483.91 264.401 483.895 C 255.01 483.876 245.619 483.883 236.228 483.901 C 228.105 483.916 219.983 483.918 211.86 483.909 C 207.034 483.904 202.21 483.903 197.384 483.914 C 159.131 483.99 159.131 483.99 143.068 480.146 C 141.987 479.903 140.905 479.66 139.79 479.408 C 137.277 478.822 134.828 478.14 132.36 477.383 C 131.465 477.109 130.569 476.835 129.646 476.552 C 105.884 468.875 84.897 456.337 66.8 439.249 C 65.448 438.112 64.09 436.983 62.724 435.863 C 32.664 410.058 16.633 368.22 12.873 329.721 C 12.432 323.684 12.465 317.647 12.46 311.597 C 12.454 310.143 12.448 308.689 12.443 307.192 C 12.428 303.231 12.422 299.269 12.416 295.309 C 12.413 292.822 12.409 290.335 12.405 287.848 C 12.389 280.032 12.378 272.215 12.374 264.399 C 12.369 255.444 12.35 246.487 12.318 237.531 C 12.294 230.566 12.282 223.6 12.28 216.633 C 12.28 212.495 12.274 208.355 12.254 204.215 C 12.154 182.613 12.574 161.364 18.717 140.46 C 18.991 139.523 19.265 138.584 19.548 137.619 C 27.777 111.087 42.745 89.246 61.273 68.959 C 62.004 68.134 62.735 67.311 63.487 66.463 C 95.594 32.13 143.615 16.748 189.525 16.874 Z';
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
  };
@@ -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: '0 0 500 500', path: 'M 189.525 16.874 C 190.808 16.873 192.091 16.873 193.413 16.872 C 196.941 16.869 200.467 16.873 203.992 16.878 C 207.81 16.884 211.625 16.881 215.442 16.881 C 222.042 16.88 228.642 16.884 235.242 16.891 C 244.784 16.899 254.326 16.902 263.868 16.904 C 279.352 16.906 294.837 16.913 310.322 16.924 C 325.357 16.933 340.393 16.941 355.428 16.947 C 356.82 16.947 356.82 16.947 358.24 16.947 C 362.896 16.949 367.55 16.95 372.206 16.951 C 410.783 16.963 449.359 16.982 487.935 17.007 C 487.954 19.653 487.967 22.299 487.978 24.945 C 487.986 26.06 487.986 26.06 487.994 27.198 C 488.011 33.049 487.504 38.516 486.278 44.226 C 485.989 45.603 485.989 45.603 485.694 47.009 C 479.753 73.338 467.128 96.459 449.249 116.488 C 448.329 117.521 447.408 118.554 446.46 119.619 C 434.313 132.43 419.889 141.304 403.93 148.543 C 402.995 148.995 402.061 149.447 401.097 149.912 C 387.969 155.816 373.086 157.455 358.886 158.492 C 337.903 160.08 318.12 166.235 302.238 180.598 C 301.228 181.476 301.228 181.476 300.196 182.373 C 290.186 191.634 284.215 203.986 280.409 216.88 C 276.039 231.615 269.265 243.914 255.779 252.324 C 245.106 257.877 235.96 259.309 224.105 259.493 C 207.259 259.997 192.415 263.213 180.305 275.865 C 171.548 286.39 167.399 299.632 168.491 313.239 C 169.76 324.473 175.361 336.705 183.966 344.189 C 197.7 354.156 210.093 357.822 227.074 356.348 C 240.814 353.699 252.153 345.546 260.235 334.242 C 264.938 325.96 267.358 317.705 268.395 308.295 C 270.357 290.773 276.223 278.048 289.803 266.539 C 299.876 258.871 311.257 253.264 324.085 253.285 C 324.795 253.283 325.507 253.282 326.238 253.28 C 328.597 253.276 330.956 253.287 333.315 253.3 C 335.015 253.301 336.714 253.301 338.415 253.3 C 343.014 253.298 347.612 253.311 352.211 253.327 C 357.024 253.34 361.836 253.341 366.649 253.344 C 375.754 253.351 384.859 253.369 393.963 253.391 C 404.333 253.416 414.703 253.429 425.071 253.439 C 446.395 253.463 467.718 253.502 489.041 253.551 C 489.12 263.833 489.178 274.116 489.213 284.399 C 489.231 289.177 489.253 293.955 489.292 298.732 C 489.713 353.119 479.51 398.095 440.406 438.144 C 402.556 475.574 354.507 484.025 303.516 483.908 C 298.647 483.9 293.777 483.908 288.908 483.913 C 280.739 483.918 272.57 483.91 264.401 483.895 C 255.01 483.876 245.619 483.883 236.228 483.901 C 228.105 483.916 219.983 483.918 211.86 483.909 C 207.034 483.904 202.21 483.903 197.384 483.914 C 159.131 483.99 159.131 483.99 143.068 480.146 C 141.987 479.903 140.905 479.66 139.79 479.408 C 137.277 478.822 134.828 478.14 132.36 477.383 C 131.465 477.109 130.569 476.835 129.646 476.552 C 105.884 468.875 84.897 456.337 66.8 439.249 C 65.448 438.112 64.09 436.983 62.724 435.863 C 32.664 410.058 16.633 368.22 12.873 329.721 C 12.432 323.684 12.465 317.647 12.46 311.597 C 12.454 310.143 12.448 308.689 12.443 307.192 C 12.428 303.231 12.422 299.269 12.416 295.309 C 12.413 292.822 12.409 290.335 12.405 287.848 C 12.389 280.032 12.378 272.215 12.374 264.399 C 12.369 255.444 12.35 246.487 12.318 237.531 C 12.294 230.566 12.282 223.6 12.28 216.633 C 12.28 212.495 12.274 208.355 12.254 204.215 C 12.154 182.613 12.574 161.364 18.717 140.46 C 18.991 139.523 19.265 138.584 19.548 137.619 C 27.777 111.087 42.745 89.246 61.273 68.959 C 62.004 68.134 62.735 67.311 63.487 66.463 C 95.594 32.13 143.615 16.748 189.525 16.874 Z' };
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' };
@@ -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. Every colour, the font and the corner radius are `--flung-*` properties a
3
- * page can set on the element; each part has a `part` name for `::part()`. Follows the page's light or dark scheme
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;