@apliteni/apliteni-ui 0.129.0 → 0.131.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/package.json +2 -1
- package/react/README.md +7 -0
- package/react/dist/index.d.ts +31 -1
- package/react/dist/index.js +602 -559
- package/src/components/account-nav.js +4 -7
- package/src/components/avatar.js +53 -0
- package/src/components/dropdown.js +36 -10
- package/src/components/shell.js +3 -2
- package/src/components/toasts.js +38 -14
- package/src/components/topbar.js +4 -3
- package/src/index.css +1 -0
- package/src/index.d.ts +8 -0
- package/src/index.js +1 -0
- package/src/inline.js +2 -0
- package/src/logic/avatar.js +28 -0
- package/src/styles/avatar.css +42 -0
- package/src/styles/callout.css +13 -3
- package/src/styles/dropdown.css +18 -0
- package/src/styles/layout.css +3 -6
- package/src/styles/topbar.css +5 -2
- package/src/tokens/tokens.css +23 -0
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// the published name docs/components.md documents.
|
|
6
6
|
// why: docs/components.md#the-page-shell
|
|
7
7
|
import { esc } from './index.js';
|
|
8
|
+
import { avatarInitials } from '../logic/avatar.js';
|
|
8
9
|
|
|
9
10
|
// nav.js item objects. Labels are raw text — every nav primitive escapes, so a
|
|
10
11
|
// pre-escaped `&` would come out as `&`. `key` means credentials;
|
|
@@ -38,10 +39,6 @@ export const accountMenuNav = () => ACCOUNT_NAV.map(toMenuTuple);
|
|
|
38
39
|
// beside ACCOUNT_NAV for the same reason ACCOUNT_NAV lives here: both files
|
|
39
40
|
// need it and shell.js already imports topbar.js.
|
|
40
41
|
//
|
|
41
|
-
// The
|
|
42
|
-
//
|
|
43
|
-
export const initials = (name
|
|
44
|
-
const from = String(name ?? '').trim() || String(email ?? '').split('@')[0] || '';
|
|
45
|
-
const parts = from.split(/[\s._-]+/).filter(Boolean).map((w) => w[0]);
|
|
46
|
-
return (parts.slice(0, 2).join('') || '?').toUpperCase();
|
|
47
|
-
};
|
|
42
|
+
// The displayed name alone supplies the letters. The part draws its user glyph
|
|
43
|
+
// when no name is shown.
|
|
44
|
+
export const initials = (name) => avatarInitials(name);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { esc, icon } from './index.js';
|
|
2
|
+
import { safeUrl } from '../html.js';
|
|
3
|
+
import { avatarHue, avatarInitials } from '../logic/avatar.js';
|
|
4
|
+
export { avatarHue, avatarInitials, AVATAR_HUES } from '../logic/avatar.js';
|
|
5
|
+
|
|
6
|
+
// Avatar — a person's round mark. The letters come from `name`, the hue from a hash of
|
|
7
|
+
// `key`, and a photo covers the letter. A photo that fails to load removes itself, so the
|
|
8
|
+
// letter shows and not the browser's broken-image glyph.
|
|
9
|
+
// With no name the mark is the `user` glyph; `empty` is a dashed ring for "no one yet".
|
|
10
|
+
// It is decoration beside the name it stands for, so it is hidden from assistive
|
|
11
|
+
// technology, and it has no cursor, hover or focus of its own.
|
|
12
|
+
// why: docs/components.md#avatar
|
|
13
|
+
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
14
|
+
const wiredDocs = new WeakSet();
|
|
15
|
+
|
|
16
|
+
function wireAvatarPhotos(doc) {
|
|
17
|
+
if (!doc || wiredDocs.has(doc)) return;
|
|
18
|
+
wiredDocs.add(doc);
|
|
19
|
+
doc.querySelectorAll('.ui-avatar > img[data-avatar-photo]').forEach((img) => {
|
|
20
|
+
if (!img.complete) return;
|
|
21
|
+
if (img.naturalWidth > 0) img.classList.add('is-loaded');
|
|
22
|
+
else img.remove();
|
|
23
|
+
});
|
|
24
|
+
doc.addEventListener('load', (event) => {
|
|
25
|
+
if (event.target?.matches?.('.ui-avatar > img[data-avatar-photo]')) event.target.classList.add('is-loaded');
|
|
26
|
+
}, true);
|
|
27
|
+
doc.addEventListener('error', (event) => {
|
|
28
|
+
if (event.target?.matches?.('.ui-avatar > img[data-avatar-photo]')) event.target.remove();
|
|
29
|
+
}, true);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (typeof document !== 'undefined') wireAvatarPhotos(document);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} [o]
|
|
36
|
+
* @param {string|number} [o.key] the app's stable id for the person; picks the hue
|
|
37
|
+
* @param {string} [o.name] the person's name; empty draws the `user` glyph
|
|
38
|
+
* @param {string} [o.initials] the letters to draw, when the caller has already chosen them
|
|
39
|
+
* @param {string} [o.src] a photo URL; the letters show if it fails to load
|
|
40
|
+
* @param {24|32} [o.size] the mark's size in px
|
|
41
|
+
* @param {boolean} [o.empty] a dashed ring in place of a person
|
|
42
|
+
* @returns {string} html
|
|
43
|
+
*/
|
|
44
|
+
export function avatar({ key, name, initials, src, size = 24, empty = false } = {}) {
|
|
45
|
+
if (typeof document !== 'undefined') wireAvatarPhotos(document);
|
|
46
|
+
const base = ['ui-avatar', size === 32 && 'ui-avatar--32'];
|
|
47
|
+
if (empty) return `<span class="${esc(cx(...base, 'ui-avatar--empty'))}" aria-hidden="true"></span>`;
|
|
48
|
+
const letters = initials || avatarInitials(name);
|
|
49
|
+
if (!letters) return `<span class="${esc(cx(...base, 'ui-avatar--anon'))}" aria-hidden="true">${icon('user')}</span>`;
|
|
50
|
+
const hue = avatarHue(key ?? name ?? letters);
|
|
51
|
+
const img = src ? `<img data-avatar-photo src="${esc(safeUrl(src))}" alt="" loading="lazy">` : '';
|
|
52
|
+
return `<span class="${esc(cx(...base, `ui-avatar--h${hue}`))}" data-initials="${letters.length}" aria-hidden="true">${esc(letters)}${img}</span>`;
|
|
53
|
+
}
|
|
@@ -16,6 +16,7 @@ export { dropdownMatch, dropdownFiltering, dropdownAvail } from '../logic/dropdo
|
|
|
16
16
|
// wireDropdown(container); // or let wireTopbar() do it
|
|
17
17
|
import { esc, icon } from './index.js';
|
|
18
18
|
import { safeUrl } from '../html.js';
|
|
19
|
+
import { avatar } from './avatar.js';
|
|
19
20
|
// The fade's own length, read off the stylesheet rather than copied. why: src/motion.js
|
|
20
21
|
import { transitionMs } from '../motion.js';
|
|
21
22
|
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
@@ -39,7 +40,7 @@ function ddItem(it, listbox, ext) {
|
|
|
39
40
|
const role = listbox ? 'option' : 'menuitem';
|
|
40
41
|
const asLink = !!it.href && !disabled && !listbox;
|
|
41
42
|
const tag = asLink ? 'a' : 'div';
|
|
42
|
-
const lead = it.icon ? `<span class="ui-dropdown__ic">${icon(it.icon)}</span>` : '';
|
|
43
|
+
const lead = it.avatar ? avatar(it.avatar) : it.icon ? `<span class="ui-dropdown__ic">${icon(it.icon)}</span>` : '';
|
|
43
44
|
const desc = it.description ? `<span class="ui-dropdown__desc">${esc(it.description)}</span>` : '';
|
|
44
45
|
const main = `<span class="ui-dropdown__main"><span class="ui-dropdown__label">${esc(it.label)}</span>${desc}</span>`;
|
|
45
46
|
const badge = ddBadge(it.badge);
|
|
@@ -87,14 +88,25 @@ const ddIsRow = (it) => it && it !== '---' && !it.separator;
|
|
|
87
88
|
|
|
88
89
|
let _ddSeq = 0;
|
|
89
90
|
|
|
90
|
-
|
|
91
|
+
// "Search 214 people": the size of the whole set, in the placeholder and in the field's
|
|
92
|
+
// name. `count: true` counts the rows, less a "No one" row, which is not a person.
|
|
93
|
+
// why: docs/components.md#a-dropdown-with-a-search-field
|
|
94
|
+
function ddCount(count, rows) {
|
|
95
|
+
if (count == null || count === false) return null;
|
|
96
|
+
const n = count === true ? rows.filter((it) => ddIsRow(it) && !it.avatar?.empty).length : Number(count);
|
|
97
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ddSearchContext(search, id, rows) {
|
|
91
101
|
const o = search === true ? {} : search;
|
|
102
|
+
const n = ddCount(o.count, rows);
|
|
103
|
+
const counted = n == null ? null : `Search ${n} ${o.noun || 'people'}`;
|
|
92
104
|
return {
|
|
93
105
|
base: id || `ui-dd-${++_ddSeq}`,
|
|
94
106
|
n: 0,
|
|
95
107
|
q: o.query || '',
|
|
96
|
-
label: o.label,
|
|
97
|
-
placeholder: o.placeholder || 'Search',
|
|
108
|
+
label: o.label || counted,
|
|
109
|
+
placeholder: o.placeholder || counted || 'Search',
|
|
98
110
|
empty: o.empty || 'No match for “{q}”',
|
|
99
111
|
hint: o.hint || 'Check the spelling, or try fewer letters.',
|
|
100
112
|
};
|
|
@@ -146,7 +158,7 @@ function ddSearchBody({ items, sections }, sx, name, scroll) {
|
|
|
146
158
|
* @param {string} [o.value] current value shown in the trigger
|
|
147
159
|
* @param {string} [o.placeholder] shown when there is no value
|
|
148
160
|
* @param {string} [o.variant] 'select' (listbox) | 'menu' (inferred from items)
|
|
149
|
-
* @param {Array} [o.items] [{ label, value?, description?, icon?, badge?, selected?, disabled?, href?, danger? }]
|
|
161
|
+
* @param {Array} [o.items] [{ label, value?, description?, icon?, avatar?, badge?, selected?, disabled?, href?, danger? }]
|
|
150
162
|
* @param {Array} [o.sections] [{ label, items }] — grouped alternative to items
|
|
151
163
|
* @param {string} [o.foot] content for a pinned block at the panel's bottom edge,
|
|
152
164
|
* drawn as `.ui-dropdown__foot` — raw HTML, so escape your own text
|
|
@@ -160,8 +172,10 @@ function ddSearchBody({ items, sections }, sx, name, scroll) {
|
|
|
160
172
|
* @param {boolean} [o.disabled] the trigger is off: no stop, no panel, the kit's
|
|
161
173
|
* unavailable paint. #580
|
|
162
174
|
* @param {string} [o.ariaLabel] accessible name for the panel and trigger
|
|
163
|
-
* @param {boolean|object} [o.search] true, or { placeholder, label, empty, hint, query } —
|
|
164
|
-
* a field above the rows that filters them; `empty` may carry {q}
|
|
175
|
+
* @param {boolean|object} [o.search] true, or { placeholder, label, empty, hint, query, count, noun } —
|
|
176
|
+
* a field above the rows that filters them; `empty` may carry {q}; `count` (a number, or
|
|
177
|
+
* true to count the rows) puts the size of the set in the placeholder and the field's
|
|
178
|
+
* name, "Search 214 people", and `noun` replaces "people"
|
|
165
179
|
* @returns {string} html
|
|
166
180
|
*/
|
|
167
181
|
export function dropdown({
|
|
@@ -174,13 +188,18 @@ export function dropdown({
|
|
|
174
188
|
const isSelect = variant === 'select' || (variant == null && flat.some((it) => it && (it.selected || it.value != null)));
|
|
175
189
|
const listRole = isSelect ? 'listbox' : 'menu';
|
|
176
190
|
const cur = value != null ? value : (isSelect ? (flat.find((it) => it && it.selected)?.label) : null);
|
|
177
|
-
const sx = search ? ddSearchContext(search, id) : null;
|
|
191
|
+
const sx = search ? ddSearchContext(search, id, flat) : null;
|
|
178
192
|
const name = ariaLabel || (label ? String(label).replace(/:\s*$/, '') : '') || 'Options';
|
|
179
193
|
|
|
194
|
+
// A picker of people: the trigger leads with the chosen row's mark, and with no value
|
|
195
|
+
// a dashed ring beside the placeholder. why: docs/components.md#avatar
|
|
196
|
+
const marked = isSelect && flat.some((it) => it && it.avatar);
|
|
197
|
+
const chosen = marked && cur != null ? flat.find((it) => it && it.label === cur) : null;
|
|
198
|
+
const lead = marked && (chosen?.avatar || cur == null) ? avatar(chosen?.avatar ?? { empty: true }) : '';
|
|
180
199
|
const trig = triggerContent != null
|
|
181
200
|
? triggerContent
|
|
182
|
-
: `${label ? `<span class="ui-dropdown__pre">${esc(label)}</span>` : ''}` +
|
|
183
|
-
`<span class="ui-dropdown__value">${esc(cur != null ? cur : placeholder)}</span>`;
|
|
201
|
+
: `${lead}${label ? `<span class="ui-dropdown__pre">${esc(label)}</span>` : ''}` +
|
|
202
|
+
`<span class="${esc(cx('ui-dropdown__value', marked && cur == null && 'is-placeholder'))}">${esc(cur != null ? cur : placeholder)}</span>`;
|
|
184
203
|
const triggerAttrs = [
|
|
185
204
|
`class="${esc(cx('ui-dropdown__trigger', triggerClass))}"`,
|
|
186
205
|
'type="button"',
|
|
@@ -962,6 +981,13 @@ function selectOption(dd, item) {
|
|
|
962
981
|
const valueEl = dd.querySelector('[data-dropdown-trigger] .ui-dropdown__value');
|
|
963
982
|
const label = item.querySelector('.ui-dropdown__label');
|
|
964
983
|
if (valueEl && label) valueEl.textContent = label.textContent;
|
|
984
|
+
// A picker of people carries the row's mark up into the trigger.
|
|
985
|
+
const mark = item.querySelector(':scope > .ui-avatar');
|
|
986
|
+
const lead = dd.querySelector('[data-dropdown-trigger] > .ui-avatar');
|
|
987
|
+
if (mark && lead) lead.replaceWith(mark.cloneNode(true));
|
|
988
|
+
else if (mark) dd.querySelector('[data-dropdown-trigger]')?.prepend(mark.cloneNode(true));
|
|
989
|
+
else lead?.remove();
|
|
990
|
+
valueEl?.classList.remove('is-placeholder');
|
|
965
991
|
}
|
|
966
992
|
|
|
967
993
|
// Click-outside, Escape and the repositioning sweep, registered once per document
|
package/src/components/shell.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// why: docs/components.md#the-page-shell
|
|
5
5
|
import { topbar as productTopbar } from './topbar.js';
|
|
6
6
|
import { esc, icon, button } from './index.js';
|
|
7
|
+
import { avatar } from './avatar.js';
|
|
7
8
|
import { sidebarNav, breadcrumbs, wireNav } from './nav.js';
|
|
8
9
|
import { dropdown, wireDropdown } from './dropdown.js';
|
|
9
10
|
import { drawer, wireDrawer, closeDrawer } from './drawer.js';
|
|
@@ -67,7 +68,7 @@ const toReader = (a) => (isRecord(a) ? { name: str(a.name), email: str(a.email)
|
|
|
67
68
|
const toMenuReader = (a) => {
|
|
68
69
|
const { name, email } = toReader(a);
|
|
69
70
|
const rest = isRecord(a) && !Array.isArray(a) ? a : {};
|
|
70
|
-
const out = { ...rest, name: esc(name), email: esc(email), initials:
|
|
71
|
+
const out = { ...rest, name: esc(name), email: esc(email), initials: initials(name, email), key: email || name };
|
|
71
72
|
if (out.nav != null) out.nav = toItems(out.nav).map(toMenuTuple);
|
|
72
73
|
return out;
|
|
73
74
|
};
|
|
@@ -195,7 +196,7 @@ function settle(options) {
|
|
|
195
196
|
// inside it. why: docs/components.md#the-page-shell
|
|
196
197
|
const readerFace = (name, email, named, markOnly = false) =>
|
|
197
198
|
`<span class="ui-app__av"${named ? ` role="img" aria-label="Signed in as ${esc(named)}"` : ' aria-hidden="true"'}>`
|
|
198
|
-
+ `${
|
|
199
|
+
+ `${avatar({ key: email || name, initials: initials(name, email), size: 32 })}</span>`
|
|
199
200
|
+ (markOnly ? '' : `<span class="ui-app__who"${named ? ' aria-hidden="true"' : ''}>`
|
|
200
201
|
+ (name ? `<b>${esc(name)}</b>` : '')
|
|
201
202
|
+ (email ? `<span>${esc(email)}</span>` : '')
|
package/src/components/toasts.js
CHANGED
|
@@ -16,6 +16,9 @@ import { toast } from './index.js';
|
|
|
16
16
|
const reduceMotion = () =>
|
|
17
17
|
typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
18
18
|
|
|
19
|
+
const resolve = (container) =>
|
|
20
|
+
(typeof container === 'string' ? document.querySelector(container) : container) || null;
|
|
21
|
+
|
|
19
22
|
// Slide-and-fade a toast out, then remove it. Idempotent.
|
|
20
23
|
export function dismissToast(el) {
|
|
21
24
|
if (!el || el.dataset.leaving) return;
|
|
@@ -36,20 +39,41 @@ function wireToast(el) {
|
|
|
36
39
|
el.querySelector('.ui-toast__close')?.addEventListener('click', () => dismissToast(el));
|
|
37
40
|
|
|
38
41
|
const bar = el.querySelector('[data-toast-timer]');
|
|
39
|
-
if (bar
|
|
42
|
+
if (bar) {
|
|
40
43
|
bar.classList.add('is-running');
|
|
41
44
|
const dur = parseFloat(getComputedStyle(el).getPropertyValue('--toast-dur')) || 5;
|
|
42
|
-
let
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
45
|
+
let remaining = dur * 1000;
|
|
46
|
+
let started = Date.now();
|
|
47
|
+
let timer = setTimeout(() => dismissToast(el), remaining);
|
|
48
|
+
// The line's own pause lives in the sheet, on :hover and :focus-within, so
|
|
49
|
+
// it covers a keyboard reader too. What is left here is the countdown that
|
|
50
|
+
// has to stop with it. This runs even under reduced motion, where the line
|
|
51
|
+
// is hidden but the reader still gets the same hold.
|
|
52
|
+
let held = false;
|
|
53
|
+
const hold = () => {
|
|
54
|
+
if (held) return;
|
|
55
|
+
held = true;
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
const visualLeft = !reduceMotion()
|
|
58
|
+
? parseFloat(getComputedStyle(bar).transform.split(',')[0].slice(7))
|
|
59
|
+
: NaN;
|
|
60
|
+
remaining = Number.isFinite(visualLeft)
|
|
61
|
+
? dur * 1000 * visualLeft
|
|
62
|
+
: Math.max(0, remaining - (Date.now() - started));
|
|
63
|
+
};
|
|
64
|
+
const release = () => {
|
|
65
|
+
if (!held || el.matches(':hover, :focus-within')) return;
|
|
66
|
+
held = false;
|
|
67
|
+
started = Date.now();
|
|
68
|
+
timer = setTimeout(() => dismissToast(el), remaining);
|
|
69
|
+
};
|
|
70
|
+
el.addEventListener('mouseenter', hold);
|
|
71
|
+
el.addEventListener('mouseleave', release);
|
|
72
|
+
el.addEventListener('focusin', hold);
|
|
73
|
+
// At focusout the old target has already blurred and the new one has not
|
|
74
|
+
// focused yet, so :focus-within reads false even for a move from the close
|
|
75
|
+
// button to the action. relatedTarget is the only thing that knows.
|
|
76
|
+
el.addEventListener('focusout', (e) => { if (!el.contains(e.relatedTarget)) release(); });
|
|
53
77
|
}
|
|
54
78
|
|
|
55
79
|
// Swipe-to-dismiss (ignore drags that start on a button).
|
|
@@ -77,7 +101,7 @@ function wireToast(el) {
|
|
|
77
101
|
|
|
78
102
|
// Wire every toast currently inside a container (string selector or element).
|
|
79
103
|
export function wireToastStack(container) {
|
|
80
|
-
const root =
|
|
104
|
+
const root = resolve(container);
|
|
81
105
|
if (!root) return null;
|
|
82
106
|
root.querySelectorAll('.ui-toast').forEach(wireToast);
|
|
83
107
|
return root;
|
|
@@ -85,7 +109,7 @@ export function wireToastStack(container) {
|
|
|
85
109
|
|
|
86
110
|
// Build a toast from opts, prepend it to a live stack (newest on top), wire it.
|
|
87
111
|
export function pushToast(container, opts = {}) {
|
|
88
|
-
const root =
|
|
112
|
+
const root = resolve(container);
|
|
89
113
|
if (!root) return null;
|
|
90
114
|
root.insertAdjacentHTML('afterbegin', toast(opts));
|
|
91
115
|
const el = root.firstElementChild;
|
package/src/components/topbar.js
CHANGED
|
@@ -4,6 +4,7 @@ import { brand } from '../assets/brand.js';
|
|
|
4
4
|
import { icon, sun, moon } from '../assets/icons.js';
|
|
5
5
|
import { esc } from './index.js';
|
|
6
6
|
import { wireDropdown } from './dropdown.js';
|
|
7
|
+
import { avatar } from './avatar.js';
|
|
7
8
|
import { accountMenuNav, initials, toMenuTuple } from './account-nav.js';
|
|
8
9
|
import { trustedAttr, trustedUrl } from '../html.js';
|
|
9
10
|
const THEME_KEY = 'apliteni-strategy-theme';
|
|
@@ -55,7 +56,7 @@ export function versionSwitcher(versions = [], activeIdx = 0) {
|
|
|
55
56
|
// shell.js computes the mark from the caller's own strings and passes it down
|
|
56
57
|
// beside them. Left out, it is computed here from `name` and `email`.
|
|
57
58
|
export function accountMenu({
|
|
58
|
-
name = 'Ada Lovelace', email = 'ada@apliteni.com', active = 'prefs', nav, initials: mark,
|
|
59
|
+
name = 'Ada Lovelace', email = 'ada@apliteni.com', active = 'prefs', nav, initials: mark, key,
|
|
59
60
|
} = {}) {
|
|
60
61
|
// initials() is shared with the rail's avatar — the two are the same reader
|
|
61
62
|
// on the /account preset, and they used to disagree about who that was.
|
|
@@ -76,9 +77,9 @@ export function accountMenu({
|
|
|
76
77
|
// `on` so the menu is visible in Storybook / standalone use (no /auth/me gate).
|
|
77
78
|
// Consumes the shared dropdown wiring via the generic [data-dropdown] hooks.
|
|
78
79
|
return `<div class="acct on" data-dropdown>` +
|
|
79
|
-
`<button class="avatar" data-dropdown-trigger aria-haspopup="menu" aria-expanded="false" aria-label="Account">${ini}</button>` +
|
|
80
|
+
`<button class="avatar" data-dropdown-trigger aria-haspopup="menu" aria-expanded="false" aria-label="Account">${avatar({ key: key ?? (email || name), initials: ini, size: 32 })}</button>` +
|
|
80
81
|
`<div class="amenu" data-dropdown-panel role="menu">` +
|
|
81
|
-
`<div class="ahead"
|
|
82
|
+
`<div class="ahead">${avatar({ key: key ?? (email || name), initials: ini, size: 32 })}<span class="aw"><span class="anm">${name}</span><span class="aem" title="${trustedAttr(email)}">${email}</span></span></div>` +
|
|
82
83
|
items.map(it).join('') +
|
|
83
84
|
`<div class="asep"></div><a class="aout" href="#logout" data-dd-item tabindex="-1" role="menuitem">${icon('logout')}Sign out</a>` +
|
|
84
85
|
`</div></div>`;
|
package/src/index.css
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -31,6 +31,14 @@ export declare function icon(name: string, className?: string): string;
|
|
|
31
31
|
export declare function snippet(opts?: Record<string, unknown>): string;
|
|
32
32
|
export declare function button(opts?: Record<string, unknown>): string;
|
|
33
33
|
export declare function badge(label: string, variant?: string): string;
|
|
34
|
+
export declare const AVATAR_HUES: 8;
|
|
35
|
+
/** Which of the eight person hues a key gets: FNV-1a 32 bit over its UTF-8 bytes, modulo 8. */
|
|
36
|
+
export declare function avatarHue(key: unknown): number;
|
|
37
|
+
/** The first letter of the first word and of the last word, upper-cased; '' for no name. */
|
|
38
|
+
export declare function avatarInitials(name: unknown): string;
|
|
39
|
+
export declare function avatar(opts?: {
|
|
40
|
+
key?: string | number; name?: string | null; initials?: string; src?: string; size?: 24 | 32; empty?: boolean;
|
|
41
|
+
}): string;
|
|
34
42
|
export declare const calloutIcons: Record<'neutral' | 'info' | 'success' | 'warn' | 'danger', string>;
|
|
35
43
|
export declare function callout(opts?: Record<string, unknown>): string;
|
|
36
44
|
export declare function card(opts?: Record<string, unknown>): string;
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// apliteni-ui — public entry.
|
|
2
2
|
// Styles ship separately: `import 'apliteni-ui/css'`.
|
|
3
3
|
export * from './components/index.js';
|
|
4
|
+
export * from './components/avatar.js';
|
|
4
5
|
export * from './components/dropdown.js';
|
|
5
6
|
export * from './components/tooltip.js';
|
|
6
7
|
export * from './components/tabs.js';
|
package/src/inline.js
CHANGED
|
@@ -35,6 +35,7 @@ export const styles = {
|
|
|
35
35
|
button: read('styles/button.css'),
|
|
36
36
|
card: read('styles/card.css'),
|
|
37
37
|
badge: read('styles/badge.css'),
|
|
38
|
+
avatar: read('styles/avatar.css'),
|
|
38
39
|
segmented: read('styles/segmented.css'),
|
|
39
40
|
tabs: read('styles/tabs.css'),
|
|
40
41
|
input: read('styles/input.css'),
|
|
@@ -72,6 +73,7 @@ export const cssText = [
|
|
|
72
73
|
styles.button,
|
|
73
74
|
styles.card,
|
|
74
75
|
styles.badge,
|
|
76
|
+
styles.avatar,
|
|
75
77
|
styles.segmented,
|
|
76
78
|
styles.tabs,
|
|
77
79
|
styles.input,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** How many hues a person's mark can take. One per `--person-N` token. */
|
|
2
|
+
export const AVATAR_HUES = 8;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Which hue a person gets: FNV-1a, 32 bit, over the UTF-8 bytes of the app's stable
|
|
6
|
+
* key, modulo the number of hues. The kit ships it so two apps give one person one hue.
|
|
7
|
+
*/
|
|
8
|
+
export function avatarHue(key) {
|
|
9
|
+
let h = 0x811c9dc5;
|
|
10
|
+
for (const byte of new TextEncoder().encode(String(key ?? ''))) {
|
|
11
|
+
h ^= byte;
|
|
12
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
13
|
+
}
|
|
14
|
+
return h % AVATAR_HUES;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The letters of a mark: the first letter of the first word and of the last word, so a
|
|
19
|
+
* one-word name gives one letter. Empty when there is no name to read.
|
|
20
|
+
*/
|
|
21
|
+
export function avatarInitials(name) {
|
|
22
|
+
const words = String(name ?? '').normalize('NFC').trim().split(/\s+/).filter(Boolean);
|
|
23
|
+
if (!words.length) return '';
|
|
24
|
+
const segmenter = typeof Intl.Segmenter === 'function' ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;
|
|
25
|
+
const first = (w) => segmenter ? segmenter.segment(w)[Symbol.iterator]().next().value.segment : Array.from(w)[0];
|
|
26
|
+
const letters = words.length === 1 ? [first(words[0])] : [first(words[0]), first(words[words.length - 1])];
|
|
27
|
+
return letters.join('').toLocaleUpperCase();
|
|
28
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/* Avatar — a person's round mark. Solid fill in the person's hue, the letters in the
|
|
2
|
+
solid-signal ink. Only the part's own paint is here: a control that holds one keeps
|
|
3
|
+
its own cursor, hover and ring. why: docs/components.md#avatar */
|
|
4
|
+
.ui-avatar {
|
|
5
|
+
--ui-avatar-size: 24px;
|
|
6
|
+
--ui-avatar-hue: var(--person-0);
|
|
7
|
+
inline-size: var(--ui-avatar-size);
|
|
8
|
+
block-size: var(--ui-avatar-size);
|
|
9
|
+
flex: none;
|
|
10
|
+
display: inline-grid;
|
|
11
|
+
place-items: center;
|
|
12
|
+
position: relative;
|
|
13
|
+
overflow: hidden;
|
|
14
|
+
border-radius: 50%;
|
|
15
|
+
font-size: var(--text-xs);
|
|
16
|
+
font-weight: var(--weight-bold);
|
|
17
|
+
line-height: 1;
|
|
18
|
+
vertical-align: middle;
|
|
19
|
+
background: var(--ui-avatar-hue); /* code-bg: inherit — an opaque fill with its own ink. */
|
|
20
|
+
color: var(--signal-solid-ink);
|
|
21
|
+
}
|
|
22
|
+
.ui-avatar--32 { --ui-avatar-size: 32px; font-size: var(--text-base); }
|
|
23
|
+
/* Two letters in 24px leave the type at the kit's smallest step; in 32px one step down. */
|
|
24
|
+
.ui-avatar--32[data-initials="2"] { font-size: var(--text-sm); }
|
|
25
|
+
.ui-avatar--h0 { --ui-avatar-hue: var(--person-0); }
|
|
26
|
+
.ui-avatar--h1 { --ui-avatar-hue: var(--person-1); }
|
|
27
|
+
.ui-avatar--h2 { --ui-avatar-hue: var(--person-2); }
|
|
28
|
+
.ui-avatar--h3 { --ui-avatar-hue: var(--person-3); }
|
|
29
|
+
.ui-avatar--h4 { --ui-avatar-hue: var(--person-4); }
|
|
30
|
+
.ui-avatar--h5 { --ui-avatar-hue: var(--person-5); }
|
|
31
|
+
.ui-avatar--h6 { --ui-avatar-hue: var(--person-6); }
|
|
32
|
+
.ui-avatar--h7 { --ui-avatar-hue: var(--person-7); }
|
|
33
|
+
|
|
34
|
+
/* The photo covers the letters, so one that fails leaves them showing. */
|
|
35
|
+
.ui-avatar img { position: absolute; inset: 0; inline-size: 100%; block-size: 100%; object-fit: cover; opacity: 0; }
|
|
36
|
+
.ui-avatar img.is-loaded { opacity: 1; }
|
|
37
|
+
.ui-avatar svg { inline-size: 62%; block-size: 62%; stroke: currentColor; fill: none; stroke-width: 2.5; }
|
|
38
|
+
|
|
39
|
+
/* No name: the user glyph on the kit's quiet fill. */
|
|
40
|
+
.ui-avatar--anon { background: var(--surface-3); /* code-bg: inherit — a glyph, no text on it. */ /* muted-ink: glyph — a person with no name */ color: var(--muted); }
|
|
41
|
+
/* No one: a dashed ring of the same size. */
|
|
42
|
+
.ui-avatar--empty { background: none; border: 1px dashed var(--muted); /* muted-ink: glyph — the ring is a mark, not text */ color: var(--muted); }
|
package/src/styles/callout.css
CHANGED
|
@@ -93,9 +93,16 @@
|
|
|
93
93
|
.ui-toast__close:hover { color: var(--text); }
|
|
94
94
|
.ui-toast__close:focus-visible { outline: var(--ring); outline-offset: var(--ring-offset); border-radius: var(--radius-xs); }
|
|
95
95
|
|
|
96
|
-
/* auto-dismiss progress
|
|
96
|
+
/* auto-dismiss progress line — .is-running is added by wireToastStack() */
|
|
97
97
|
.ui-toast__timer { position: absolute; left: 0; bottom: 0; height: 3px; width: 100%; background: var(--toast-accent); opacity: 0.85; transform-origin: left; }
|
|
98
|
-
.ui-toast__timer.is-running { animation: ui-toast-timer var(--toast-dur, 5s) linear forwards; /* motion: ambient — the
|
|
98
|
+
.ui-toast__timer.is-running { animation: ui-toast-timer var(--toast-dur, 5s) linear forwards; /* motion: ambient — the line spends the toast's dismiss timer, which the caller sets */ }
|
|
99
|
+
/* The line spends the notice's own timer, so it stops exactly when the timer
|
|
100
|
+
does: under the pointer, and while focus is anywhere inside. Holding both
|
|
101
|
+
pauses here rather than in each face's event wiring keeps the line and the
|
|
102
|
+
countdown from drifting apart, and gives a keyboard reader the same reprieve
|
|
103
|
+
a pointer reader gets. */
|
|
104
|
+
.ui-toast:hover > .ui-toast__timer.is-running,
|
|
105
|
+
.ui-toast:focus-within > .ui-toast__timer.is-running { animation-play-state: paused; }
|
|
99
106
|
|
|
100
107
|
/* Legacy style classes share the neutral card. */
|
|
101
108
|
.ui-toast--soft, .ui-toast--solid, .ui-toast--outline { background: var(--surface); color: var(--text); }
|
|
@@ -110,7 +117,10 @@
|
|
|
110
117
|
|
|
111
118
|
@media (prefers-reduced-motion: reduce) {
|
|
112
119
|
.ui-toast, .ui-toast.is-leaving { animation: none; }
|
|
113
|
-
|
|
120
|
+
/* A line that cannot move sits full for the whole five seconds and reads as
|
|
121
|
+
"no time spent yet" — the opposite of what it is there to say. The notice
|
|
122
|
+
still expires on time; only the false readout goes. */
|
|
123
|
+
.ui-toast__timer { display: none; }
|
|
114
124
|
}
|
|
115
125
|
|
|
116
126
|
/* stack container for queued toasts */
|
package/src/styles/dropdown.css
CHANGED
|
@@ -425,3 +425,21 @@
|
|
|
425
425
|
/* muted-ink: state — off, unset, disabled or archived badge */
|
|
426
426
|
color: var(--muted);
|
|
427
427
|
}
|
|
428
|
+
|
|
429
|
+
/* A picker of people. A mark leads the trigger and each row; with no value the trigger
|
|
430
|
+
shows the dashed ring beside the placeholder in the placeholder ink. The mark's own
|
|
431
|
+
margins give back the room it takes, so the trigger keeps its height.
|
|
432
|
+
why: docs/components.md#avatar */
|
|
433
|
+
.ui-dropdown__trigger > .ui-avatar {
|
|
434
|
+
--ui-avatar-pull: 4px;
|
|
435
|
+
--ui-avatar-pull-start: 10px;
|
|
436
|
+
margin-block: calc(var(--ui-avatar-pull) * -1);
|
|
437
|
+
margin-inline-start: calc(var(--ui-avatar-pull-start) * -1);
|
|
438
|
+
}
|
|
439
|
+
.ui-dropdown__value.is-placeholder { /* muted-ink: placeholder — no person chosen */ color: var(--muted); font-weight: var(--weight-medium); }
|
|
440
|
+
|
|
441
|
+
/* A dropdown that is a form control takes its field's width, and its panel the
|
|
442
|
+
trigger's. why: docs/components.md#avatar */
|
|
443
|
+
.ui-field > .ui-dropdown { display: grid; }
|
|
444
|
+
.ui-field > .ui-dropdown > .ui-dropdown__trigger > .ui-dropdown__chevron { margin-left: auto; }
|
|
445
|
+
.ui-field > .ui-dropdown > .ui-dropdown__panel { inline-size: 100%; min-width: 0; }
|
package/src/styles/layout.css
CHANGED
|
@@ -161,13 +161,10 @@
|
|
|
161
161
|
.ui-app__av {
|
|
162
162
|
width: var(--ui-app-av); height: var(--ui-app-av); flex: none;
|
|
163
163
|
margin-inline-start: calc((var(--ui-nav-strip) - var(--ui-app-av)) / 2);
|
|
164
|
-
|
|
165
|
-
background: var(--surface); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */
|
|
166
|
-
color: var(--strong);
|
|
167
|
-
display: grid; place-items: center;
|
|
168
|
-
font-size: calc(var(--text-xs) + 1px);
|
|
169
|
-
font-weight: var(--weight-semibold);
|
|
164
|
+
display: grid;
|
|
170
165
|
}
|
|
166
|
+
/* The mark is the Avatar part in the reader's hue, filling this box like the top bar's. */
|
|
167
|
+
.ui-app__av > .ui-avatar { --ui-avatar-size: 100%; }
|
|
171
168
|
.ui-app__who { font-size: calc(var(--text-sm) - 0.5px); line-height: 1.3; min-width: 0; }
|
|
172
169
|
.ui-app__who b { display: block; color: var(--strong); font-weight: var(--weight-semibold); }
|
|
173
170
|
/* nowrap is what makes the ellipsis fire — without it the address wraps, and
|
package/src/styles/topbar.css
CHANGED
|
@@ -209,7 +209,7 @@
|
|
|
209
209
|
src/styles/dropdown.css:271 `.ui-dropdown__item:hover`.
|
|
210
210
|
why: docs/foundations.md#elevation */
|
|
211
211
|
.vopt:hover { outline: 1px solid var(--control-edge); outline-offset: -1px; background: var(--surface); /* code-bg: inherit — control paint; the containing surface is the ground, for a chip and for a band alike. */ }
|
|
212
|
-
/* The row is a keyboard stop: src/components/dropdown.js:
|
|
212
|
+
/* The row is a keyboard stop: src/components/dropdown.js:1191
|
|
213
213
|
`items[next < 0 ? 0 : next]?.focus()` moves focus onto a `[data-dd-item]` with
|
|
214
214
|
the arrow keys, and this is one. It takes the ring on its own 10px radius, the
|
|
215
215
|
same way `.amenu a` above does. #482 */
|
|
@@ -306,6 +306,10 @@
|
|
|
306
306
|
what the kit's rows already chose. why: docs/foundations.md#the-focus-ring */
|
|
307
307
|
.acct .avatar:hover:not(:focus-visible) { border-color: var(--accent); }
|
|
308
308
|
.avatar:focus-visible { outline: var(--ring); outline-offset: var(--ring-offset); }
|
|
309
|
+
/* The account button holding an Avatar: the part paints the circle in the reader's hue and
|
|
310
|
+
the button keeps its edge, cursor, hover and ring. why: docs/components.md#avatar */
|
|
311
|
+
.avatar:has(> .ui-avatar) { background: none; padding: 0; overflow: hidden; }
|
|
312
|
+
.avatar > .ui-avatar { --ui-avatar-size: 100%; }
|
|
309
313
|
.amenu {
|
|
310
314
|
position: absolute;
|
|
311
315
|
right: 0; top: 44px;
|
|
@@ -372,7 +376,6 @@
|
|
|
372
376
|
border-radius: 14px 14px 0 0;
|
|
373
377
|
display: flex; align-items: center; gap: 11px;
|
|
374
378
|
}
|
|
375
|
-
.amenu .ahead .avatar { cursor: default; }
|
|
376
379
|
.amenu .ahead .aw { min-width: 0; display: flex; flex-direction: column; gap: 1px; line-height: 1.35; }
|
|
377
380
|
.amenu .ahead .anm { color: var(--strong); font-size: var(--text-sm); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
378
381
|
.amenu .ahead .aem { color: var(--text); font-size: calc(var(--text-xs) + 0.5px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
package/src/tokens/tokens.css
CHANGED
|
@@ -257,6 +257,19 @@
|
|
|
257
257
|
--signal-solid-info: var(--cyan);
|
|
258
258
|
--signal-solid-neutral: var(--muted);
|
|
259
259
|
|
|
260
|
+
/* Person hues: the eight fills an Avatar takes from a hash of the person's key. Fixed
|
|
261
|
+
values and not accent tints, so a person keeps one hue under every accent and in
|
|
262
|
+
every app. Each is an existing kit value for this theme, and --signal-solid-ink
|
|
263
|
+
holds 4.5:1 on all eight. why: docs/components.md#avatar */
|
|
264
|
+
--person-0: #bd8cff;
|
|
265
|
+
--person-1: #5ab0ff;
|
|
266
|
+
--person-2: #20dcf5;
|
|
267
|
+
--person-3: #3ad9a0;
|
|
268
|
+
--person-4: #98ff8f;
|
|
269
|
+
--person-5: #ffcf6a;
|
|
270
|
+
--person-6: #ff8a5c;
|
|
271
|
+
--person-7: #f5aec6;
|
|
272
|
+
|
|
260
273
|
/* DEPRECATED since #295: no surface below the floating step casts, and the floating
|
|
261
274
|
step reads --elev-drop rather than any of these. Published still, so a consumer
|
|
262
275
|
reading one gets nothing rather than an unresolved var(), and a consumer who wants a
|
|
@@ -446,6 +459,16 @@
|
|
|
446
459
|
--signal-solid-danger: var(--chip-danger-ink);
|
|
447
460
|
--signal-solid-warn: var(--chip-warn-ink);
|
|
448
461
|
--signal-solid-info: var(--chip-info-ink);
|
|
462
|
+
|
|
463
|
+
/* Person hues, light: the deepened forms, so white holds on every one. */
|
|
464
|
+
--person-0: #6a2dcc;
|
|
465
|
+
--person-1: #005ab4;
|
|
466
|
+
--person-2: #096a7c;
|
|
467
|
+
--person-3: #076c48;
|
|
468
|
+
--person-4: #145a26;
|
|
469
|
+
--person-5: #825900;
|
|
470
|
+
--person-6: #a8370c;
|
|
471
|
+
--person-7: #b7295f;
|
|
449
472
|
--signal-solid-neutral: var(--muted);
|
|
450
473
|
|
|
451
474
|
/* Deprecated and unread — see the dark block. */
|