@fikar-ai/design 1.5.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 +1379 -0
- package/brand/apps/account.svg +4 -0
- package/brand/apps/chat.svg +3 -0
- package/brand/apps/cost-calculator.svg +12 -0
- package/brand/apps/crs.svg +9 -0
- package/brand/apps/dictionary.svg +5 -0
- package/brand/apps/platform.svg +11 -0
- package/brand/apps/super-admin.svg +9 -0
- package/brand/favicon/apple-touch-icon.png +0 -0
- package/brand/favicon/favicon-16x16.png +0 -0
- package/brand/favicon/favicon-32x32.png +0 -0
- package/brand/favicon/favicon-48x48.png +0 -0
- package/brand/favicon/favicon.ico +0 -0
- package/brand/favicon/favicon.svg +11 -0
- package/brand/favicon/icon-192.png +0 -0
- package/brand/favicon/icon-512.png +0 -0
- package/brand/favicon/manifest.webmanifest +8 -0
- package/brand/fikar-loader.svg +10 -0
- package/brand/fikar-logo-mono.svg +6 -0
- package/brand/fikar-logo-reversed.svg +9 -0
- package/brand/fikar-logo.svg +15 -0
- package/components.css +577 -0
- package/fonts/Charter-license.txt +3 -0
- package/fonts/charter_bold.woff2 +0 -0
- package/fonts/charter_bold_italic.woff2 +0 -0
- package/fonts/charter_italic.woff2 +0 -0
- package/fonts/charter_regular.woff2 +0 -0
- package/fonts.css +37 -0
- package/logo.css +24 -0
- package/package.json +68 -0
- package/preset.d.ts +5 -0
- package/preset.js +67 -0
- package/recipes/brand-loader.html +4 -0
- package/recipes/detail-panel.html +35 -0
- package/recipes/empty-state.html +6 -0
- package/recipes/entity-badge.html +6 -0
- package/recipes/filter-chip.html +5 -0
- package/recipes/launcher.html +24 -0
- package/recipes/list-row.html +21 -0
- package/recipes/search-results.html +43 -0
- package/recipes/search.html +18 -0
- package/recipes/segmented.html +7 -0
- package/recipes/shell-no-sidebar.html +60 -0
- package/recipes/shell.html +89 -0
- package/recipes/status-badge.html +10 -0
- package/recipes/theme-menu.html +21 -0
- package/recipes/time.html +9 -0
- package/recipes/user-menu.html +21 -0
- package/shell.js +324 -0
- package/templates/jinja/launcher.html +45 -0
- package/templates/jinja/shell.html +97 -0
- package/templates/jinja/theme-menu.html +27 -0
- package/templates/jinja/user-menu.html +55 -0
- package/tokens.css +122 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!-- User menu: the avatar and account panel, last in the top bar cluster. Shown here signed in with a name, no picture and an extra item.
|
|
2
|
+
With a picture the initials span becomes <img class="avatar" src="..." alt="">, and data-initials stays, because shell.js swaps a broken picture for the initials. With no name, the .user-menu-id block holds the email once
|
|
3
|
+
and has no .user-menu-email. Account settings is optional, extra items go after it, and Sign out is last. The second separator is only
|
|
4
|
+
there when something comes before Sign out. Sign out may also be <button class="launcher-item user-menu-item" type="button" role="menuitem">.
|
|
5
|
+
data-fk-menu opts in to the behaviour in shell.js; leave it off when the page wires the menu itself. -->
|
|
6
|
+
<div class="launcher" data-fk-menu>
|
|
7
|
+
<button class="user-menu-btn" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="Open account menu" data-initials="AM">
|
|
8
|
+
<span class="avatar" aria-hidden="true">AM</span>
|
|
9
|
+
</button>
|
|
10
|
+
<div class="launcher-panel user-menu-panel" role="menu">
|
|
11
|
+
<div class="user-menu-id">
|
|
12
|
+
<span class="user-menu-name">Alex Morgan</span>
|
|
13
|
+
<span class="user-menu-email">alex@example.com</span>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="user-menu-sep" role="separator"></div>
|
|
16
|
+
<a class="launcher-item user-menu-item" role="menuitem" href="/account">Account settings</a>
|
|
17
|
+
<a class="launcher-item user-menu-item" role="menuitem" href="/workspace">Workspace settings</a>
|
|
18
|
+
<div class="user-menu-sep" role="separator"></div>
|
|
19
|
+
<a class="launcher-item user-menu-item" role="menuitem" href="/logout">Sign out</a>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
package/shell.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/* @fikar-ai/design app shell behaviour. Classic script, no dependencies.
|
|
2
|
+
Binds the classes documented in the README: .shell, .shell-menu, .shell-close,
|
|
3
|
+
.shell-scrim, .shell-collapse, any menu that opts in with data-fk-menu, and the search
|
|
4
|
+
box that registers with data-fk-search.
|
|
5
|
+
Exposes window.FikarShell.init(root), and the two time formatters formatRelative and formatExact (KAN-342). */
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
var KEY = 'fikar.shell.collapsed';
|
|
10
|
+
var DESKTOP = '(min-width: 1024px)';
|
|
11
|
+
var bound = new WeakSet();
|
|
12
|
+
var boundMenus = new WeakSet();
|
|
13
|
+
var menuKeysBound = false;
|
|
14
|
+
var desktop = window.matchMedia ? window.matchMedia(DESKTOP) : null;
|
|
15
|
+
|
|
16
|
+
// Reading or writing storage can throw (private mode, blocked site data).
|
|
17
|
+
// The class on the element is the state for the session, so every failure is safe to ignore.
|
|
18
|
+
function readCollapsed() {
|
|
19
|
+
try { return window.localStorage.getItem(KEY) === '1'; } catch (e) { return false; }
|
|
20
|
+
}
|
|
21
|
+
function writeCollapsed(on) {
|
|
22
|
+
// Writes "0" on expand, like @fikar-ai/design-react, so both behave the same.
|
|
23
|
+
// A removed key and "0" are both read as open.
|
|
24
|
+
try { window.localStorage.setItem(KEY, on ? '1' : '0'); } catch (e) { /* see readCollapsed */ }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isDesktop() {
|
|
28
|
+
return desktop ? desktop.matches : true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A .no-sidebar shell has no drawer or collapse control, so there is nothing to bind and no state to
|
|
32
|
+
// restore. Skipping it also keeps a stored "1" from putting .is-collapsed on it.
|
|
33
|
+
function shellsIn(root) {
|
|
34
|
+
var found = Array.prototype.slice.call(root.querySelectorAll('.shell'));
|
|
35
|
+
if (root.classList && root.classList.contains('shell')) found.unshift(root);
|
|
36
|
+
return found.filter(function (shell) { return !shell.classList.contains('no-sidebar'); });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setCollapsed(shell, toggle, on) {
|
|
40
|
+
shell.classList.toggle('is-collapsed', on);
|
|
41
|
+
if (!toggle) return;
|
|
42
|
+
toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
43
|
+
toggle.setAttribute('title', on ? 'Expand sidebar' : 'Collapse sidebar');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function bind(shell) {
|
|
47
|
+
if (bound.has(shell)) return;
|
|
48
|
+
bound.add(shell);
|
|
49
|
+
|
|
50
|
+
var menu = shell.querySelector('.shell-menu');
|
|
51
|
+
var close = shell.querySelector('.shell-close');
|
|
52
|
+
var scrim = shell.querySelector('.shell-scrim');
|
|
53
|
+
var toggle = shell.querySelector('.shell-collapse');
|
|
54
|
+
|
|
55
|
+
function setOpen(on, returnFocus) {
|
|
56
|
+
shell.classList.toggle('nav-open', on);
|
|
57
|
+
if (menu) menu.setAttribute('aria-expanded', on ? 'true' : 'false');
|
|
58
|
+
if (scrim) {
|
|
59
|
+
if (on) scrim.removeAttribute('hidden');
|
|
60
|
+
else scrim.setAttribute('hidden', '');
|
|
61
|
+
}
|
|
62
|
+
if (!on && returnFocus && menu) menu.focus();
|
|
63
|
+
}
|
|
64
|
+
function isOpen() {
|
|
65
|
+
return shell.classList.contains('nav-open');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setCollapsed(shell, toggle, readCollapsed());
|
|
69
|
+
|
|
70
|
+
if (toggle) {
|
|
71
|
+
toggle.addEventListener('click', function () {
|
|
72
|
+
// The rule only applies from 1024px and the control is hidden below it.
|
|
73
|
+
if (!isDesktop()) return;
|
|
74
|
+
var on = !shell.classList.contains('is-collapsed');
|
|
75
|
+
setCollapsed(shell, toggle, on);
|
|
76
|
+
writeCollapsed(on);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (menu) menu.addEventListener('click', function () { setOpen(true, false); });
|
|
80
|
+
if (close) close.addEventListener('click', function () { setOpen(false, true); });
|
|
81
|
+
if (scrim) scrim.addEventListener('click', function () { setOpen(false, true); });
|
|
82
|
+
|
|
83
|
+
document.addEventListener('keydown', function (e) {
|
|
84
|
+
if (e.key === 'Escape' && isOpen()) setOpen(false, true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Growing past 1024px hides the menu button, so the scrim would otherwise
|
|
88
|
+
// stay over the page with no visible way to dismiss it.
|
|
89
|
+
if (desktop) {
|
|
90
|
+
var onChange = function () { if (desktop.matches && isOpen()) setOpen(false, false); };
|
|
91
|
+
if (desktop.addEventListener) desktop.addEventListener('change', onChange);
|
|
92
|
+
else if (desktop.addListener) desktop.addListener(onChange);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Menus: any element with data-fk-menu, such as the launcher, the theme menu and the user menu. The root carries .open, the first
|
|
97
|
+
// [aria-haspopup] inside it is the trigger, and [role="menuitem"] and [role="menuitemradio"] are the rows. Nothing here runs on a
|
|
98
|
+
// page with no such element, and a menu the page wires itself has no data-fk-menu, so it is never handled twice.
|
|
99
|
+
var MENU = '[data-fk-menu]';
|
|
100
|
+
var RADIO = '[role="menuitemradio"]';
|
|
101
|
+
var ITEM = '[role="menuitem"],' + RADIO;
|
|
102
|
+
|
|
103
|
+
function menusIn(root) {
|
|
104
|
+
var found = Array.prototype.slice.call(root.querySelectorAll(MENU));
|
|
105
|
+
if (root.hasAttribute && root.hasAttribute('data-fk-menu')) found.unshift(root);
|
|
106
|
+
return found;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function openMenus() {
|
|
110
|
+
return Array.prototype.slice.call(document.querySelectorAll(MENU + '.open'));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function setMenuOpen(menu, on, returnFocus) {
|
|
114
|
+
var trigger = menu.querySelector('[aria-haspopup]');
|
|
115
|
+
menu.classList.toggle('open', on);
|
|
116
|
+
if (!trigger) return;
|
|
117
|
+
trigger.setAttribute('aria-expanded', on ? 'true' : 'false');
|
|
118
|
+
if (!on && returnFocus) trigger.focus();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function itemOf(menu, node) {
|
|
122
|
+
for (; node && node !== menu; node = node.parentNode) {
|
|
123
|
+
var role = node.getAttribute && node.getAttribute('role');
|
|
124
|
+
if (role === 'menuitem' || role === 'menuitemradio') return node;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The only radio menu is the theme menu. The chosen row takes .current and aria-checked at once, so the page needs no code to show
|
|
130
|
+
// it, and the page hears about a change on the menu root as a bubbling fk-theme-change event with the row's data-value in detail.value.
|
|
131
|
+
// Choosing the row that is already checked changes nothing and sends nothing.
|
|
132
|
+
function choose(menu, row) {
|
|
133
|
+
if (row.getAttribute('aria-checked') === 'true') return;
|
|
134
|
+
Array.prototype.forEach.call(menu.querySelectorAll(RADIO), function (other) {
|
|
135
|
+
other.classList.toggle('current', other === row);
|
|
136
|
+
other.setAttribute('aria-checked', other === row ? 'true' : 'false');
|
|
137
|
+
});
|
|
138
|
+
menu.dispatchEvent(new window.CustomEvent('fk-theme-change', { bubbles: true, detail: { value: row.getAttribute('data-value') } }));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A picture that fails to load is swapped for the initials the markup carries in data-initials on the trigger.
|
|
142
|
+
// The span is the same one the markup has when there is no picture. error does not bubble, so it is bound on the image,
|
|
143
|
+
// and an image that failed before this ran is caught by complete with no width.
|
|
144
|
+
function showInitialsIfBroken(trigger) {
|
|
145
|
+
var img = trigger.querySelector('img');
|
|
146
|
+
var initials = trigger.getAttribute('data-initials');
|
|
147
|
+
if (!img || !initials) return;
|
|
148
|
+
function swap() {
|
|
149
|
+
var span = document.createElement('span');
|
|
150
|
+
span.className = 'avatar';
|
|
151
|
+
span.setAttribute('aria-hidden', 'true');
|
|
152
|
+
span.textContent = initials;
|
|
153
|
+
trigger.replaceChild(span, img);
|
|
154
|
+
}
|
|
155
|
+
if (img.complete && img.naturalWidth === 0) swap();
|
|
156
|
+
else img.addEventListener('error', swap);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function bindMenu(menu) {
|
|
160
|
+
if (boundMenus.has(menu)) return;
|
|
161
|
+
boundMenus.add(menu);
|
|
162
|
+
|
|
163
|
+
var trigger = menu.querySelector('[aria-haspopup]');
|
|
164
|
+
if (trigger) {
|
|
165
|
+
showInitialsIfBroken(trigger);
|
|
166
|
+
trigger.addEventListener('click', function () {
|
|
167
|
+
var on = !menu.classList.contains('open');
|
|
168
|
+
// Opening one menu closes the others, so two panels are never open at once.
|
|
169
|
+
if (on) openMenus().forEach(function (other) { if (other !== menu) setMenuOpen(other, false, false); });
|
|
170
|
+
setMenuOpen(menu, on, false);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
// A chosen row closes the panel and leaves focus with whatever the row does (a link navigates, a button acts).
|
|
174
|
+
// A radio row acts on the menu itself and there is nowhere else for focus to go, so it returns to the trigger.
|
|
175
|
+
menu.addEventListener('click', function (e) {
|
|
176
|
+
var item = itemOf(menu, e.target);
|
|
177
|
+
if (!item) return;
|
|
178
|
+
var radio = item.getAttribute('role') === 'menuitemradio';
|
|
179
|
+
if (radio) choose(menu, item);
|
|
180
|
+
setMenuOpen(menu, false, radio);
|
|
181
|
+
});
|
|
182
|
+
bindMenuKeys();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// One pair of document listeners serves every menu, added when the first menu is bound.
|
|
186
|
+
function bindMenuKeys() {
|
|
187
|
+
if (menuKeysBound) return;
|
|
188
|
+
menuKeysBound = true;
|
|
189
|
+
|
|
190
|
+
// pointerdown rather than click: iOS Safari does not deliver click to the document for taps on non-interactive elements.
|
|
191
|
+
document.addEventListener('pointerdown', function (e) {
|
|
192
|
+
openMenus().forEach(function (menu) { if (!menu.contains(e.target)) setMenuOpen(menu, false, false); });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
document.addEventListener('keydown', function (e) {
|
|
196
|
+
var menu = openMenus()[0];
|
|
197
|
+
if (!menu) return;
|
|
198
|
+
if (e.key === 'Escape') {
|
|
199
|
+
setMenuOpen(menu, false, true);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
var items = Array.prototype.slice.call(menu.querySelectorAll(ITEM));
|
|
203
|
+
var at = items.indexOf(document.activeElement);
|
|
204
|
+
var next = { ArrowDown: at + 1, ArrowUp: at < 0 ? items.length - 1 : at - 1, Home: 0, End: items.length - 1 }[e.key];
|
|
205
|
+
if (next === undefined || !items.length || !menu.contains(document.activeElement)) return;
|
|
206
|
+
e.preventDefault();
|
|
207
|
+
items[(next + items.length) % items.length].focus();
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Search (since 1.3.0): the page registers one search box by putting data-fk-search on its input, and / or Cmd-K or Ctrl-K focuses it. Both modifiers
|
|
212
|
+
// are accepted on every platform, so nothing sniffs the operating system, and Cmd-K works from any focus while / is left alone in a field. The first
|
|
213
|
+
// registered box that is on screen wins, so a second one is never chosen and a box hidden at a narrow width is skipped. Nothing here runs on a page with no
|
|
214
|
+
// registered box and no .search-clear, and a page that wires its own clear button is not handled twice, since clearing twice changes nothing.
|
|
215
|
+
var SEARCH = '[data-fk-search]';
|
|
216
|
+
var searchBound = false;
|
|
217
|
+
|
|
218
|
+
function isTyping(node) {
|
|
219
|
+
return !!node && (/^(INPUT|TEXTAREA|SELECT)$/.test(node.tagName) || node.isContentEditable === true);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function searchBox() {
|
|
223
|
+
var boxes = document.querySelectorAll(SEARCH);
|
|
224
|
+
for (var i = 0; i < boxes.length; i++) if (boxes[i].getClientRects().length > 0) return boxes[i];
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function focusSearch(e) {
|
|
229
|
+
if (e.defaultPrevented || e.isComposing || e.altKey) return;
|
|
230
|
+
var slash = e.key === '/' && !e.ctrlKey && !e.metaKey;
|
|
231
|
+
var palette = (e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey);
|
|
232
|
+
if (!slash && !palette) return;
|
|
233
|
+
if (slash && isTyping(e.target)) return;
|
|
234
|
+
var box = searchBox();
|
|
235
|
+
if (!box) return;
|
|
236
|
+
// Without this the / would be typed into the box that has just taken focus, and Ctrl-K would reach the browser's own bar.
|
|
237
|
+
e.preventDefault();
|
|
238
|
+
box.focus();
|
|
239
|
+
box.select();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Empties the box, tells the page (a plain input event, so its own listeners hear it), and keeps focus in the box so typing can go on.
|
|
243
|
+
function clearSearch(e) {
|
|
244
|
+
for (var node = e.target; node; node = node.parentNode) {
|
|
245
|
+
if (!node.classList || !node.classList.contains('search-clear')) continue;
|
|
246
|
+
var input = node.parentNode.querySelector('input');
|
|
247
|
+
if (!input) return;
|
|
248
|
+
input.value = '';
|
|
249
|
+
input.dispatchEvent(new window.Event('input', { bubbles: true }));
|
|
250
|
+
input.focus();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function bindSearch(root) {
|
|
256
|
+
if (searchBound) return;
|
|
257
|
+
var here = root.querySelector(SEARCH + ',.search-clear') || (root.hasAttribute && root.hasAttribute('data-fk-search'));
|
|
258
|
+
if (!here) return;
|
|
259
|
+
searchBound = true;
|
|
260
|
+
document.addEventListener('keydown', focusSearch);
|
|
261
|
+
document.addEventListener('click', clearSearch);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Time (KAN-342): the one wording for a moment that every surface uses, so a page never formats it on its own. formatRelative says "just now" under a
|
|
265
|
+
// minute, "4 min ago" under an hour and "4 h ago" under a day, and after that gives formatExact, "22 Sep, 08:16" in the reader's time zone on a 24 hour
|
|
266
|
+
// clock, with the year ("22 Sep 2025, 08:16") once the moment is a year or more from now. A moment more than a minute ahead is also given exactly, since a
|
|
267
|
+
// clock that is a few seconds fast is common and a real future date is not "ago". The month names are written out because Intl gives "Sept" for
|
|
268
|
+
// September in en-GB. The same rules are in @fikar-ai/design-react (format-time.ts), and test/fixtures/time-rules.json holds the table both are tested against.
|
|
269
|
+
// `value` is a Date or an ISO string, `now` (a Date, for tests) defaults to the current time, and a value that is not a date gives an empty string.
|
|
270
|
+
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
271
|
+
var MINUTE = 60000;
|
|
272
|
+
var HOUR = 60 * MINUTE;
|
|
273
|
+
var DAY = 24 * HOUR;
|
|
274
|
+
var YEAR = 365 * DAY;
|
|
275
|
+
|
|
276
|
+
function toTime(value) {
|
|
277
|
+
return new Date(value).getTime();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function two(n) {
|
|
281
|
+
return n < 10 ? '0' + n : String(n);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function formatExact(value, now) {
|
|
285
|
+
var time = toTime(value);
|
|
286
|
+
if (isNaN(time)) return '';
|
|
287
|
+
var date = new Date(time);
|
|
288
|
+
var year = Math.abs((now === undefined ? Date.now() : toTime(now)) - time) >= YEAR ? ' ' + date.getFullYear() : '';
|
|
289
|
+
return date.getDate() + ' ' + MONTHS[date.getMonth()] + year + ', ' + two(date.getHours()) + ':' + two(date.getMinutes());
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function formatRelative(value, now) {
|
|
293
|
+
var time = toTime(value);
|
|
294
|
+
if (isNaN(time)) return '';
|
|
295
|
+
var age = (now === undefined ? Date.now() : toTime(now)) - time;
|
|
296
|
+
if (age < -MINUTE || age >= DAY) return formatExact(value, now);
|
|
297
|
+
if (age < MINUTE) return 'just now';
|
|
298
|
+
if (age < HOUR) return Math.floor(age / MINUTE) + ' min ago';
|
|
299
|
+
return Math.floor(age / HOUR) + ' h ago';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function init(root) {
|
|
303
|
+
var scope = root || document;
|
|
304
|
+
shellsIn(scope).forEach(bind);
|
|
305
|
+
menusIn(scope).forEach(bindMenu);
|
|
306
|
+
bindSearch(scope);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Runs while the document is still parsing. Only the class is touched, because
|
|
310
|
+
// the rest of the shell may not exist yet. Full binding waits for the whole tree.
|
|
311
|
+
function restoreEarly() {
|
|
312
|
+
if (!readCollapsed()) return;
|
|
313
|
+
shellsIn(document).forEach(function (shell) { shell.classList.add('is-collapsed'); });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
window.FikarShell = { init: init, formatRelative: formatRelative, formatExact: formatExact };
|
|
317
|
+
|
|
318
|
+
if (document.readyState === 'loading') {
|
|
319
|
+
restoreEarly();
|
|
320
|
+
document.addEventListener('DOMContentLoaded', function () { init(document); });
|
|
321
|
+
} else {
|
|
322
|
+
init(document);
|
|
323
|
+
}
|
|
324
|
+
})();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{#- App launcher, same markup as recipes/launcher.html. Renders nothing for an empty list.
|
|
2
|
+
|
|
3
|
+
{% from "fikar/launcher.html" import launcher %}
|
|
4
|
+
{{ launcher(apps) }}
|
|
5
|
+
{{ launcher(apps, data_fk_menu=true) }}
|
|
6
|
+
|
|
7
|
+
apps list of items: label, href, id, and optional current (bool), already filtered and ordered by the host. The id picks the glyph
|
|
8
|
+
(chat, platform, crs, dictionary, cost-calculator, super-admin, account), and an item with no id, or one the package does
|
|
9
|
+
not know, shows the first letter of its label in the same tile. A hint is accepted and not rendered (since 1.5.0).
|
|
10
|
+
data_fk_menu optional, default false. True puts data-fk-menu on the root so shell.js opens and closes it. The default output is
|
|
11
|
+
free of the attribute, so a page that wires the launcher itself and updates the package is not handled twice.
|
|
12
|
+
Set it only once the page's own wiring is removed, and on every menu in the page (see "Moving a page to shell.js" in the README) -#}
|
|
13
|
+
{#- The glyph map below is generated from brand/apps/*.svg. Do not edit it here: change the SVG and run `node scripts/build-app-icons.mjs`. -#}
|
|
14
|
+
{#- generated:app-icons begin (scripts/build-app-icons.mjs, from brand/apps/*.svg) -#}
|
|
15
|
+
{% set app_icons = {
|
|
16
|
+
'account': '<circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/>',
|
|
17
|
+
'chat': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
|
18
|
+
'cost-calculator': '<rect width="16" height="20" x="4" y="2" rx="2"/><line x1="8" x2="16" y1="6" y2="6"/><line x1="16" x2="16" y1="14" y2="18"/><path d="M16 10h.01"/><path d="M12 10h.01"/><path d="M8 10h.01"/><path d="M12 14h.01"/><path d="M8 14h.01"/><path d="M12 18h.01"/><path d="M8 18h.01"/>',
|
|
19
|
+
'crs': '<circle cx="12" cy="4.5" r="2.5"/><path d="m10.2 6.3-3.9 3.9"/><circle cx="4.5" cy="12" r="2.5"/><path d="M7 12h10"/><circle cx="19.5" cy="12" r="2.5"/><path d="m13.8 17.7 3.9-3.9"/><circle cx="12" cy="19.5" r="2.5"/>',
|
|
20
|
+
'dictionary': '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/><path d="m8 13 4-7 4 7"/><path d="M9.1 11h5.7"/>',
|
|
21
|
+
'platform': '<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>',
|
|
22
|
+
'super-admin': '<path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/>',
|
|
23
|
+
} %}
|
|
24
|
+
{#- generated:app-icons end -#}
|
|
25
|
+
{% macro launcher(apps, data_fk_menu=false) -%}
|
|
26
|
+
{%- if apps %}
|
|
27
|
+
<div class="launcher"{{ ' data-fk-menu' if data_fk_menu }}>
|
|
28
|
+
<button class="launcher-btn" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="Switch app">
|
|
29
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
30
|
+
<circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/>
|
|
31
|
+
<circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/>
|
|
32
|
+
<circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/>
|
|
33
|
+
</svg>
|
|
34
|
+
</button>
|
|
35
|
+
<div class="launcher-panel launcher-apps" role="menu">
|
|
36
|
+
{%- for app in apps %}
|
|
37
|
+
{%- set body = app_icons.get(app.id) if app.id else none %}
|
|
38
|
+
<a class="launcher-item{{ ' current' if app.current }}" role="menuitem" href="{{ app.href }}"{{ ' aria-current="page"'|safe if app.current }}>
|
|
39
|
+
<span class="launcher-tile" aria-hidden="true">{% if body %}<svg class="app-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{{ body|safe }}</svg>{% else %}{{ (app.label or '')[:1]|upper }}{% endif %}</span>{{ app.label }}
|
|
40
|
+
</a>
|
|
41
|
+
{%- endfor %}
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
{%- endif %}
|
|
45
|
+
{%- endmacro %}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{#- App shell, same markup as recipes/shell.html. No host logic: everything comes in as arguments.
|
|
2
|
+
|
|
3
|
+
{% from "fikar/shell.html" import shell %}
|
|
4
|
+
{% call shell(brand=brand, nav=nav, foot=foot, cluster=cluster, start=start, scroll=true) %}page content{% endcall %}
|
|
5
|
+
|
|
6
|
+
brand markup for the logo and word, used in the sidebar and in the top bar
|
|
7
|
+
nav list of entries. An item has label, href, icon (svg markup), and optional active (bool), title (defaults
|
|
8
|
+
to label), badge (text) and class (extra class names, after nav-item and active; use nav-item-back for
|
|
9
|
+
the link back to the app the person came from). An entry with heading: true and a label is a group
|
|
10
|
+
heading instead.
|
|
11
|
+
foot optional markup for the side foot. Nothing renders when it is empty or only whitespace, so there is no bare divider.
|
|
12
|
+
cluster markup for the top bar cluster (launcher, theme control, avatar menu, in that order)
|
|
13
|
+
start optional markup for the top bar, left of the cluster (a search box, a page title). Nothing renders when empty.
|
|
14
|
+
scroll optional bool. True makes the nav scroll inside the sidebar, for long lists.
|
|
15
|
+
nav_label optional accessible name of the sidebar nav. Default 'Main'.
|
|
16
|
+
fill optional bool. True adds is-fill: the shell is one viewport tall at every width, and the main area is a
|
|
17
|
+
padless column for a page that scrolls inside itself (a conversation with a docked composer).
|
|
18
|
+
|
|
19
|
+
Markup arguments must be marked safe (Markup, |safe, or a {% set x %} block) when autoescape is on.
|
|
20
|
+
The call body is the main content. -#}
|
|
21
|
+
{% macro shell(brand, nav, foot, cluster, start='', scroll=false, nav_label='Main', fill=false) -%}
|
|
22
|
+
<div class="shell with-topbar{{ ' is-fill' if fill }}" id="shell">
|
|
23
|
+
<aside class="sidebar" id="sidebar">
|
|
24
|
+
<div class="brand">
|
|
25
|
+
{{ brand }}
|
|
26
|
+
<button class="shell-close iconbtn" type="button" aria-label="Close navigation">×</button>
|
|
27
|
+
</div>
|
|
28
|
+
<nav class="side{{ ' is-scroll' if scroll }}" aria-label="{{ nav_label }}">
|
|
29
|
+
{%- for item in nav %}
|
|
30
|
+
{%- if item.heading %}
|
|
31
|
+
<div class="nav-heading">{{ item.label }}</div>
|
|
32
|
+
{%- else %}
|
|
33
|
+
<a class="nav-item{{ ' active' if item.active }}{{ ' ' ~ item['class'] if item['class'] }}" href="{{ item.href }}" title="{{ item.title or item.label }}"{{ ' aria-current="page"'|safe if item.active }}>
|
|
34
|
+
{{ item.icon }}
|
|
35
|
+
<span class="nav-label">{{ item.label }}</span>
|
|
36
|
+
{%- if item.badge %}
|
|
37
|
+
<span class="nav-badge">{{ item.badge }}</span>
|
|
38
|
+
{%- endif %}
|
|
39
|
+
</a>
|
|
40
|
+
{%- endif %}
|
|
41
|
+
{%- endfor %}
|
|
42
|
+
</nav>
|
|
43
|
+
{%- if foot and foot|trim %}
|
|
44
|
+
<div class="side-foot">
|
|
45
|
+
{{ foot }}
|
|
46
|
+
</div>
|
|
47
|
+
{%- endif %}
|
|
48
|
+
<button class="shell-collapse" type="button" aria-controls="sidebar" aria-pressed="false" title="Collapse sidebar">
|
|
49
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
|
50
|
+
<span class="nav-label">Collapse</span>
|
|
51
|
+
</button>
|
|
52
|
+
</aside>
|
|
53
|
+
<header class="shell-topbar">
|
|
54
|
+
<button class="shell-menu" type="button" aria-label="Open navigation" aria-controls="sidebar" aria-expanded="false">
|
|
55
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
|
56
|
+
</button>
|
|
57
|
+
<div class="brand">{{ brand }}</div>
|
|
58
|
+
{%- if start %}
|
|
59
|
+
<div class="shell-topbar-start">{{ start }}</div>
|
|
60
|
+
{%- endif %}
|
|
61
|
+
<div class="cluster">
|
|
62
|
+
{{ cluster }}
|
|
63
|
+
</div>
|
|
64
|
+
</header>
|
|
65
|
+
<div class="shell-scrim" hidden></div>
|
|
66
|
+
<main class="main"><div class="content">{{ caller() }}</div></main>
|
|
67
|
+
</div>
|
|
68
|
+
{%- endmacro %}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
{#- The shell for an app with a top bar and no sidebar. Same markup as recipes/shell-no-sidebar.html.
|
|
72
|
+
|
|
73
|
+
{% from "fikar/shell.html" import topbar_shell %}
|
|
74
|
+
{% call topbar_shell(brand=brand, cluster=cluster, start=start) %}page content{% endcall %}
|
|
75
|
+
|
|
76
|
+
brand markup for the logo and word, shown in the top bar at every width
|
|
77
|
+
cluster markup for the top bar cluster (launcher, theme control, avatar menu, in that order)
|
|
78
|
+
start optional markup between the brand and the cluster, such as a pill and a row of links. It scrolls sideways
|
|
79
|
+
inside itself when the bar is short of room. Nothing renders when empty.
|
|
80
|
+
fill optional bool, as on shell()
|
|
81
|
+
|
|
82
|
+
There is no nav, foot or scroll argument because there is no sidebar to put them in. The call body is the main content.
|
|
83
|
+
shell.js skips this markup: it has no drawer and no collapse control. -#}
|
|
84
|
+
{% macro topbar_shell(brand, cluster, start='', fill=false) -%}
|
|
85
|
+
<div class="shell with-topbar no-sidebar{{ ' is-fill' if fill }}" id="shell">
|
|
86
|
+
<header class="shell-topbar">
|
|
87
|
+
<div class="brand">{{ brand }}</div>
|
|
88
|
+
{%- if start %}
|
|
89
|
+
<div class="shell-topbar-start">{{ start }}</div>
|
|
90
|
+
{%- endif %}
|
|
91
|
+
<div class="cluster">
|
|
92
|
+
{{ cluster }}
|
|
93
|
+
</div>
|
|
94
|
+
</header>
|
|
95
|
+
<main class="main"><div class="content">{{ caller() }}</div></main>
|
|
96
|
+
</div>
|
|
97
|
+
{%- endmacro %}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{#- Theme menu, same markup as recipes/theme-menu.html. Goes second in the shell cluster, after the launcher and before the user menu.
|
|
2
|
+
|
|
3
|
+
{% from "fikar/theme-menu.html" import theme_menu %}
|
|
4
|
+
{{ theme_menu(value=me.theme) }}
|
|
5
|
+
|
|
6
|
+
value the theme the person has now, 'light', 'dark' or 'system'. Anything else, including none, is shown as 'system'
|
|
7
|
+
|
|
8
|
+
The menu stores nothing. shell.js sends a bubbling fk-theme-change event from the root with the chosen value in detail.value, and the page applies and saves it.
|
|
9
|
+
The root carries data-fk-menu, so shell.js handles opening and closing. Leave it out of a host that wires the menu itself. -#}
|
|
10
|
+
{% macro theme_menu(value='system') -%}
|
|
11
|
+
{%- set current = value if value in ['light', 'dark'] else 'system' %}
|
|
12
|
+
<div class="launcher" data-fk-menu>
|
|
13
|
+
<button class="launcher-btn theme-menu-btn" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="Choose theme">
|
|
14
|
+
<svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
15
|
+
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
|
|
16
|
+
</svg>
|
|
17
|
+
<svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
18
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
|
19
|
+
</svg>
|
|
20
|
+
</button>
|
|
21
|
+
<div class="launcher-panel theme-menu-panel" role="menu" aria-label="Theme">
|
|
22
|
+
{%- for choice, label in [('light', 'Light'), ('dark', 'Dark'), ('system', 'System')] %}
|
|
23
|
+
<button class="launcher-item theme-menu-item{{ ' current' if choice == current }}" type="button" role="menuitemradio" aria-checked="{{ 'true' if choice == current else 'false' }}" data-value="{{ choice }}">{{ label }}</button>
|
|
24
|
+
{%- endfor %}
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
{%- endmacro %}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{#- User menu, same markup as recipes/user-menu.html. Goes last in the shell cluster, after the launcher and the theme control.
|
|
2
|
+
|
|
3
|
+
{% from "fikar/user-menu.html" import user_menu, user_initials %}
|
|
4
|
+
{{ user_menu(email=me.email, sign_out_href=sign_out_url, name=me.name, avatar_url=me.avatar_url, account_href="/account") }}
|
|
5
|
+
|
|
6
|
+
email the signed-in person's email. Required
|
|
7
|
+
sign_out_href where the Sign out link goes. Required. The address is the host's, so where a person lands stays as it is
|
|
8
|
+
name optional full name. With no name the panel shows the email once
|
|
9
|
+
avatar_url optional picture. With none, or when it fails to load, the avatar shows initials. data-initials on the trigger always carries them
|
|
10
|
+
account_href optional address of Account settings. Leave it out and the row is not rendered, for the page that would link to itself
|
|
11
|
+
items optional list of extra rows, each with label and href, rendered after Account settings and before Sign out
|
|
12
|
+
|
|
13
|
+
user_initials(name, email) is the rule for the initials: the first letter of the first word of the name and of the last word,
|
|
14
|
+
or the first letter of the email when there is no name. A single word gives one letter. Words are split on whitespace, the
|
|
15
|
+
letters are upper case, and non-ASCII letters work. It is also useful on its own when a host needs the initials elsewhere.
|
|
16
|
+
|
|
17
|
+
The root carries data-fk-menu, so shell.js handles opening and closing. Leave it out of a host that wires the menu itself. -#}
|
|
18
|
+
{% macro user_initials(name, email) -%}
|
|
19
|
+
{%- set words = (name or '').split() -%}
|
|
20
|
+
{%- if words -%}{{ (words[0][:1] ~ (words[-1][:1] if words | length > 1 else '')) | upper }}
|
|
21
|
+
{%- else -%}{{ (email or '')[:1] | upper }}{%- endif -%}
|
|
22
|
+
{%- endmacro %}
|
|
23
|
+
|
|
24
|
+
{% macro user_menu(email, sign_out_href, name='', avatar_url='', account_href='', items=none) -%}
|
|
25
|
+
{%- set full_name = (name or '').split() | join(' ') %}
|
|
26
|
+
{%- set initials = user_initials(name, email) %}
|
|
27
|
+
<div class="launcher" data-fk-menu>
|
|
28
|
+
<button class="user-menu-btn" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="Open account menu" data-initials="{{ initials }}">
|
|
29
|
+
{%- if avatar_url %}
|
|
30
|
+
<img class="avatar" src="{{ avatar_url }}" alt="">
|
|
31
|
+
{%- else %}
|
|
32
|
+
<span class="avatar" aria-hidden="true">{{ initials }}</span>
|
|
33
|
+
{%- endif %}
|
|
34
|
+
</button>
|
|
35
|
+
<div class="launcher-panel user-menu-panel" role="menu">
|
|
36
|
+
<div class="user-menu-id">
|
|
37
|
+
<span class="user-menu-name">{{ full_name or email }}</span>
|
|
38
|
+
{%- if full_name %}
|
|
39
|
+
<span class="user-menu-email">{{ email }}</span>
|
|
40
|
+
{%- endif %}
|
|
41
|
+
</div>
|
|
42
|
+
<div class="user-menu-sep" role="separator"></div>
|
|
43
|
+
{%- if account_href %}
|
|
44
|
+
<a class="launcher-item user-menu-item" role="menuitem" href="{{ account_href }}">Account settings</a>
|
|
45
|
+
{%- endif %}
|
|
46
|
+
{%- for item in items or [] %}
|
|
47
|
+
<a class="launcher-item user-menu-item" role="menuitem" href="{{ item.href }}">{{ item.label }}</a>
|
|
48
|
+
{%- endfor %}
|
|
49
|
+
{%- if account_href or items %}
|
|
50
|
+
<div class="user-menu-sep" role="separator"></div>
|
|
51
|
+
{%- endif %}
|
|
52
|
+
<a class="launcher-item user-menu-item" role="menuitem" href="{{ sign_out_href }}">Sign out</a>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
{%- endmacro %}
|