@alacris/ui 0.0.0 → 0.1.1
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/LICENSE +21 -0
- package/README.md +148 -1
- package/package.json +75 -3
- package/src/components/base.js +46 -0
- package/src/components/ui-accordion-item.js +157 -0
- package/src/components/ui-accordion.js +58 -0
- package/src/components/ui-alert.js +168 -0
- package/src/components/ui-app-bar.js +119 -0
- package/src/components/ui-autocomplete.js +371 -0
- package/src/components/ui-avatar.js +104 -0
- package/src/components/ui-backdrop.js +66 -0
- package/src/components/ui-badge.js +95 -0
- package/src/components/ui-bottom-app-bar.js +78 -0
- package/src/components/ui-bottom-nav.js +83 -0
- package/src/components/ui-breadcrumbs.js +100 -0
- package/src/components/ui-button-group.js +49 -0
- package/src/components/ui-button.js +143 -0
- package/src/components/ui-card.js +86 -0
- package/src/components/ui-carousel-item.js +53 -0
- package/src/components/ui-carousel.js +301 -0
- package/src/components/ui-checkbox.js +165 -0
- package/src/components/ui-chip-set.js +90 -0
- package/src/components/ui-chip.js +226 -0
- package/src/components/ui-container.js +59 -0
- package/src/components/ui-date-picker.js +774 -0
- package/src/components/ui-dialog.js +179 -0
- package/src/components/ui-divider.js +58 -0
- package/src/components/ui-drawer.js +169 -0
- package/src/components/ui-fab-menu.js +122 -0
- package/src/components/ui-fab.js +117 -0
- package/src/components/ui-icon-button.js +112 -0
- package/src/components/ui-icon.js +64 -0
- package/src/components/ui-list-item.js +166 -0
- package/src/components/ui-list.js +38 -0
- package/src/components/ui-loading-indicator.js +74 -0
- package/src/components/ui-menu-item.js +117 -0
- package/src/components/ui-menu.js +192 -0
- package/src/components/ui-nav-item.js +138 -0
- package/src/components/ui-nav-rail.js +111 -0
- package/src/components/ui-option.js +85 -0
- package/src/components/ui-pagination.js +173 -0
- package/src/components/ui-progress.js +100 -0
- package/src/components/ui-radio-group.js +104 -0
- package/src/components/ui-radio.js +143 -0
- package/src/components/ui-rating.js +126 -0
- package/src/components/ui-search.js +318 -0
- package/src/components/ui-select.js +418 -0
- package/src/components/ui-sheet.js +205 -0
- package/src/components/ui-side-sheet.js +221 -0
- package/src/components/ui-skeleton.js +89 -0
- package/src/components/ui-slider.js +266 -0
- package/src/components/ui-snackbar.js +233 -0
- package/src/components/ui-spinner.js +101 -0
- package/src/components/ui-split-button.js +158 -0
- package/src/components/ui-stack.js +37 -0
- package/src/components/ui-step.js +91 -0
- package/src/components/ui-stepper.js +86 -0
- package/src/components/ui-surface.js +51 -0
- package/src/components/ui-switch.js +153 -0
- package/src/components/ui-tab-panel.js +54 -0
- package/src/components/ui-tab.js +116 -0
- package/src/components/ui-table-footer.js +141 -0
- package/src/components/ui-table-toolbar.js +219 -0
- package/src/components/ui-table.js +735 -0
- package/src/components/ui-tabs.js +178 -0
- package/src/components/ui-text-field.js +319 -0
- package/src/components/ui-text.js +36 -0
- package/src/components/ui-time-picker.js +753 -0
- package/src/components/ui-toggle-button.js +170 -0
- package/src/components/ui-toggle-group.js +111 -0
- package/src/components/ui-toolbar.js +64 -0
- package/src/components/ui-tooltip.js +148 -0
- package/src/index.js +121 -0
- package/src/motion/animate.js +145 -0
- package/src/motion/flip.js +46 -0
- package/src/motion/index.js +4 -0
- package/src/motion/presence.js +104 -0
- package/src/motion/ripple.js +108 -0
- package/src/theme/apply-theme.js +142 -0
- package/src/theme/create-theme.js +70 -0
- package/src/theme/index.js +5 -0
- package/src/tokens/color.js +237 -0
- package/src/tokens/index.js +14 -0
- package/src/tokens/sys.js +76 -0
- package/src/tokens/system.js +114 -0
- package/src/tokens/typography.js +189 -0
- package/src/util/focus.js +89 -0
- package/src/util/form.js +69 -0
- package/src/util/icons.js +161 -0
- package/src/util/keys.js +110 -0
- package/src/util/position.js +102 -0
- package/src/util/table.js +230 -0
- package/types/elements.d.ts +78 -0
- package/types/index.d.ts +168 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Motion — one ergonomic layer over the Web Animations API.
|
|
2
|
+
//
|
|
3
|
+
// Principles:
|
|
4
|
+
// - Durations and easings come from the motion tokens, so JS-driven motion
|
|
5
|
+
// obeys the theme's `motion.scale` exactly like CSS transitions do —
|
|
6
|
+
// `animate(el, fx.fadeIn, { duration: 'medium2' })` resolves the token at
|
|
7
|
+
// call time.
|
|
8
|
+
// - `prefers-reduced-motion` is honored everywhere: animations jump to their
|
|
9
|
+
// end state instead of playing. CSS transitions get the same guard from
|
|
10
|
+
// `base.js`.
|
|
11
|
+
// - No timeouts, no rAF bookkeeping: WAAPI runs off the main thread where
|
|
12
|
+
// the browser can manage it, and `.finished` sequences the rest.
|
|
13
|
+
|
|
14
|
+
import { DURATIONS, EASINGS } from '../tokens/system.js';
|
|
15
|
+
|
|
16
|
+
const camelToToken = (s) => s.replace(/([A-Z]|\d+$)/g, (c) => '-' + c.toLowerCase());
|
|
17
|
+
|
|
18
|
+
/** True when the user asked for reduced motion (checked live). */
|
|
19
|
+
export const prefersReducedMotion = () =>
|
|
20
|
+
typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a duration token ('medium2', 'extraLong1') to milliseconds, reading
|
|
24
|
+
* the live CSS token so a theme's motion scale applies. Numbers pass through.
|
|
25
|
+
*/
|
|
26
|
+
export function duration(key) {
|
|
27
|
+
if (typeof key === 'number') return key;
|
|
28
|
+
const token = camelToToken(key);
|
|
29
|
+
if (typeof getComputedStyle === 'function') {
|
|
30
|
+
const raw = getComputedStyle(document.documentElement).getPropertyValue(`--ui-duration-${token}`);
|
|
31
|
+
const ms = parseFloat(raw);
|
|
32
|
+
if (!Number.isNaN(ms)) return ms;
|
|
33
|
+
}
|
|
34
|
+
return DURATIONS[token] ?? 200;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolve an easing token ('standard', 'emphasizedDecelerate') to its curve. */
|
|
38
|
+
export const easing = (key) => EASINGS[camelToToken(key)] || key;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* animate(el, keyframes, { duration: 'short4'|ms, easing: 'standard'|curve,
|
|
42
|
+
* fill: 'both', ...WAAPI options })
|
|
43
|
+
*
|
|
44
|
+
* Returns the Animation. Under reduced motion the animation still runs (so
|
|
45
|
+
* `.finished` chains keep working) but with ~zero duration — the element
|
|
46
|
+
* simply lands in its final state.
|
|
47
|
+
*/
|
|
48
|
+
export function animate(el, keyframes, opts = {}) {
|
|
49
|
+
const { duration: d = 'short4', easing: e = 'standard', fill = 'both', ...rest } = opts;
|
|
50
|
+
// Environments without WAAPI (simulated DOMs in tests) get an
|
|
51
|
+
// already-finished stand-in so `.finished` sequencing still works.
|
|
52
|
+
if (typeof el?.animate !== 'function') {
|
|
53
|
+
return { finished: Promise.resolve(), cancel() {}, finish() {}, play() {}, pause() {} };
|
|
54
|
+
}
|
|
55
|
+
const ms = prefersReducedMotion() ? 1 : duration(d);
|
|
56
|
+
const run = () => {
|
|
57
|
+
const frames = typeof keyframes === 'function' ? keyframes(el) : keyframes;
|
|
58
|
+
const anim = el.animate(frames, { duration: ms, easing: easing(e), fill, ...rest });
|
|
59
|
+
anim.finished.catch(() => {}); // cancellation is normal control flow, not an error
|
|
60
|
+
// Hidden/occluded tabs freeze the animation timeline, which would leave
|
|
61
|
+
// `.finished` chains (presence unmounts, `closed` events, snackbar queues)
|
|
62
|
+
// waiting forever. setTimeout still ticks there, so force-finish on the
|
|
63
|
+
// wall clock; in a visible tab the animation wins the race and this no-ops.
|
|
64
|
+
const delay = Number(rest.delay) || 0;
|
|
65
|
+
const guard = setTimeout(() => {
|
|
66
|
+
try { if (anim.playState === 'running' || anim.pending) anim.finish(); } catch { /* infinite/cancelled */ }
|
|
67
|
+
}, ms + delay + 100);
|
|
68
|
+
anim.finished.then(() => clearTimeout(guard), () => clearTimeout(guard));
|
|
69
|
+
return anim;
|
|
70
|
+
};
|
|
71
|
+
if (el.isConnected) return run();
|
|
72
|
+
|
|
73
|
+
// A `ref` fires while the element is still in its render fragment, before
|
|
74
|
+
// insertion. An animation created on a disconnected subtree can wedge with
|
|
75
|
+
// its first keyframe applied but its timeline never advancing — so start on
|
|
76
|
+
// the next frame, once the element is in the document. The facade keeps
|
|
77
|
+
// `.finished`/`.cancel` working either way. If rAF never fires (hidden tab),
|
|
78
|
+
// the wall-clock fallback settles without animating — the element simply
|
|
79
|
+
// rests in its CSS end state.
|
|
80
|
+
let anim = null;
|
|
81
|
+
let cancelled = false;
|
|
82
|
+
let started = false;
|
|
83
|
+
let settle;
|
|
84
|
+
const finished = new Promise((resolve) => (settle = resolve));
|
|
85
|
+
const start = () => {
|
|
86
|
+
if (cancelled || started) return;
|
|
87
|
+
started = true;
|
|
88
|
+
if (!el.isConnected) return settle();
|
|
89
|
+
anim = run();
|
|
90
|
+
anim.finished.then(settle, () => settle());
|
|
91
|
+
};
|
|
92
|
+
requestAnimationFrame(start);
|
|
93
|
+
const fallback = setTimeout(() => (started ? null : (started = true, settle())), ms + 200);
|
|
94
|
+
finished.then(() => clearTimeout(fallback));
|
|
95
|
+
return {
|
|
96
|
+
finished,
|
|
97
|
+
cancel() { cancelled = true; anim?.cancel(); settle(); },
|
|
98
|
+
finish() { anim?.finish(); },
|
|
99
|
+
play() { anim?.play(); },
|
|
100
|
+
pause() { anim?.pause(); },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Await an animation without throwing when it is cancelled mid-flight. */
|
|
105
|
+
export const settled = (anim) => anim.finished.catch(() => {});
|
|
106
|
+
|
|
107
|
+
// ------------------------------------------------------------------ presets
|
|
108
|
+
//
|
|
109
|
+
// Keyframe presets tuned to the Material motion spec. Enter presets pair with
|
|
110
|
+
// 'emphasizedDecelerate', exits with 'emphasizedAccelerate' — `animate`'s
|
|
111
|
+
// defaults are fine for simple fades.
|
|
112
|
+
|
|
113
|
+
export const fx = {
|
|
114
|
+
fadeIn: [{ opacity: 0 }, { opacity: 1 }],
|
|
115
|
+
fadeOut: [{ opacity: 1 }, { opacity: 0 }],
|
|
116
|
+
// 0.8 is the MD3 container-transform start scale (dialogs, menus).
|
|
117
|
+
scaleIn: [{ opacity: 0, transform: 'scale(0.8)' }, { opacity: 1, transform: 'scale(1)' }],
|
|
118
|
+
scaleOut: [{ opacity: 1, transform: 'scale(1)' }, { opacity: 0, transform: 'scale(0.8)' }],
|
|
119
|
+
slideInUp: [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'translateY(0)' }],
|
|
120
|
+
slideOutDown: [{ opacity: 1, transform: 'translateY(0)' }, { opacity: 0, transform: 'translateY(16px)' }],
|
|
121
|
+
slideInDown: [{ opacity: 0, transform: 'translateY(-16px)' }, { opacity: 1, transform: 'translateY(0)' }],
|
|
122
|
+
slideOutUp: [{ opacity: 1, transform: 'translateY(0)' }, { opacity: 0, transform: 'translateY(-16px)' }],
|
|
123
|
+
expandDown: (el) => {
|
|
124
|
+
const h = el?.scrollHeight || 0;
|
|
125
|
+
return [
|
|
126
|
+
{ blockSize: '0px', opacity: 0, overflow: 'hidden' },
|
|
127
|
+
{ blockSize: `${h}px`, opacity: 1, overflow: 'hidden' },
|
|
128
|
+
];
|
|
129
|
+
},
|
|
130
|
+
collapseUp: (el) => {
|
|
131
|
+
const h = el?.offsetHeight || el?.scrollHeight || 0;
|
|
132
|
+
return [
|
|
133
|
+
{ blockSize: `${h}px`, opacity: 1, overflow: 'hidden' },
|
|
134
|
+
{ blockSize: '0px', opacity: 0, overflow: 'hidden' },
|
|
135
|
+
];
|
|
136
|
+
},
|
|
137
|
+
slideInLeft: [{ transform: 'translateX(-100%)' }, { transform: 'translateX(0)' }],
|
|
138
|
+
slideOutLeft: [{ transform: 'translateX(0)' }, { transform: 'translateX(-100%)' }],
|
|
139
|
+
slideInRight: [{ transform: 'translateX(100%)' }, { transform: 'translateX(0)' }],
|
|
140
|
+
slideOutRight: [{ transform: 'translateX(0)' }, { transform: 'translateX(100%)' }],
|
|
141
|
+
// Bottom-sheet cover: the panel travels its full height, not a 16px nudge.
|
|
142
|
+
sheetIn: [{ transform: 'translateY(100%)' }, { transform: 'translateY(0)' }],
|
|
143
|
+
sheetOut: [{ transform: 'translateY(0)' }, { transform: 'translateY(100%)' }],
|
|
144
|
+
collapse: [{ blockSize: 'var(--ui-collapse-size, auto)', opacity: 1 }, { blockSize: '0', opacity: 0 }],
|
|
145
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// withFlip — animate a list reorder with the FLIP technique.
|
|
2
|
+
//
|
|
3
|
+
// Alacris's `each` moves the DOM nodes that changed position, synchronously.
|
|
4
|
+
// Wrap the mutation and every child that moved glides to its new place;
|
|
5
|
+
// children that appeared fade in. (Removed children are already gone by the
|
|
6
|
+
// time we can look — exits inside a hot list are a deliberate non-goal; use
|
|
7
|
+
// `presence` for individually dismissable items.)
|
|
8
|
+
//
|
|
9
|
+
// withFlip(listEl, () => state.rows.sort(byName));
|
|
10
|
+
//
|
|
11
|
+
// Keyed by node identity, which is exactly what `each` keeps stable.
|
|
12
|
+
|
|
13
|
+
import { animate, fx } from './animate.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* withFlip(container, mutate, { duration, easing, stagger })
|
|
17
|
+
* Returns whatever `mutate` returns.
|
|
18
|
+
*/
|
|
19
|
+
export function withFlip(container, mutate, opts = {}) {
|
|
20
|
+
const before = new Map();
|
|
21
|
+
for (const el of container.children) before.set(el, el.getBoundingClientRect());
|
|
22
|
+
|
|
23
|
+
const result = mutate();
|
|
24
|
+
|
|
25
|
+
let i = 0;
|
|
26
|
+
for (const el of container.children) {
|
|
27
|
+
const was = before.get(el);
|
|
28
|
+
const delay = opts.stagger ? i++ * opts.stagger : 0;
|
|
29
|
+
if (!was) {
|
|
30
|
+
animate(el, fx.fadeIn, { duration: opts.duration ?? 'medium1', delay, fill: 'backwards' });
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const now = el.getBoundingClientRect();
|
|
34
|
+
const dx = was.left - now.left;
|
|
35
|
+
const dy = was.top - now.top;
|
|
36
|
+
if (dx || dy) {
|
|
37
|
+
animate(el, [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], {
|
|
38
|
+
duration: opts.duration ?? 'medium3',
|
|
39
|
+
easing: opts.easing ?? 'emphasized',
|
|
40
|
+
delay,
|
|
41
|
+
fill: 'backwards',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// presence — enter/exit animation for conditional templates.
|
|
2
|
+
//
|
|
3
|
+
// Alacris removes a conditional subtree the instant its condition turns false,
|
|
4
|
+
// which is correct — and too soon for an exit animation. `presence` gives a
|
|
5
|
+
// condition a lifecycle: mount + enter animation when it becomes truthy, exit
|
|
6
|
+
// animation *then* unmount when it becomes falsy.
|
|
7
|
+
//
|
|
8
|
+
// setup({ open }) {
|
|
9
|
+
// return html`
|
|
10
|
+
// <div class="host">
|
|
11
|
+
// ${presence(open, () => html`<div class="sheet">…</div>`, {
|
|
12
|
+
// enter: fx.slideInUp,
|
|
13
|
+
// exit: fx.slideOutDown,
|
|
14
|
+
// })}
|
|
15
|
+
// </div>`;
|
|
16
|
+
// }
|
|
17
|
+
//
|
|
18
|
+
// The returned node is a `display: contents` anchor, so it is invisible to
|
|
19
|
+
// layout. Call `presence` in a child position during `setup` (or under an
|
|
20
|
+
// active `root()`), because it creates an effect that must be owned.
|
|
21
|
+
|
|
22
|
+
import { effect, onCleanup, render } from '@alacris/core';
|
|
23
|
+
import { animate, fx } from './animate.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* presence(when, view, opts)
|
|
27
|
+
*
|
|
28
|
+
* when — signal/thunk; truthy shows the view
|
|
29
|
+
* view — () => template (rebuilt fresh on every entry)
|
|
30
|
+
* opts — enter, exit: keyframes or false to disable (default fade)
|
|
31
|
+
* enterDuration/exitDuration, enterEasing/exitEasing: token names
|
|
32
|
+
* target: CSS selector for the element to animate (default: the
|
|
33
|
+
* view's first element)
|
|
34
|
+
* onEntered: called after the enter animation finishes
|
|
35
|
+
* onExited: called after the exit animation finishes
|
|
36
|
+
*/
|
|
37
|
+
export function presence(when, view, opts = {}) {
|
|
38
|
+
const anchor = document.createElement('span');
|
|
39
|
+
anchor.style.display = 'contents';
|
|
40
|
+
|
|
41
|
+
const enterFx = opts.enter === false ? null : opts.enter || fx.fadeIn;
|
|
42
|
+
const exitFx = opts.exit === false ? null : opts.exit || fx.fadeOut;
|
|
43
|
+
const target = () => (opts.target ? anchor.querySelector(opts.target) : anchor.firstElementChild);
|
|
44
|
+
|
|
45
|
+
let dispose = null; // active render
|
|
46
|
+
let exiting = null; // in-flight exit animation
|
|
47
|
+
|
|
48
|
+
effect(() => {
|
|
49
|
+
const show = !!when();
|
|
50
|
+
if (show) {
|
|
51
|
+
// Re-entering while the exit plays: cut it short and remount fresh.
|
|
52
|
+
if (exiting) {
|
|
53
|
+
exiting.cancel();
|
|
54
|
+
exiting = null;
|
|
55
|
+
dispose?.();
|
|
56
|
+
dispose = null;
|
|
57
|
+
}
|
|
58
|
+
if (!dispose) {
|
|
59
|
+
dispose = render(view(), anchor);
|
|
60
|
+
const el = target();
|
|
61
|
+
if (el && enterFx) {
|
|
62
|
+
const anim = animate(el, enterFx, {
|
|
63
|
+
duration: opts.enterDuration ?? 'medium2',
|
|
64
|
+
easing: opts.enterEasing ?? 'emphasizedDecelerate',
|
|
65
|
+
});
|
|
66
|
+
anim.finished.then(() => {
|
|
67
|
+
try { anim.cancel(); } catch {}
|
|
68
|
+
opts.onEntered?.();
|
|
69
|
+
}, () => {});
|
|
70
|
+
} else if (opts.onEntered) {
|
|
71
|
+
opts.onEntered();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} else if (dispose && !exiting) {
|
|
75
|
+
const el = target();
|
|
76
|
+
const done = () => {
|
|
77
|
+
if (!exiting) return; // re-entry already tore this down
|
|
78
|
+
exiting = null;
|
|
79
|
+
dispose?.();
|
|
80
|
+
dispose = null;
|
|
81
|
+
opts.onExited?.();
|
|
82
|
+
};
|
|
83
|
+
if (el && exitFx) {
|
|
84
|
+
exiting = animate(el, exitFx, {
|
|
85
|
+
duration: opts.exitDuration ?? 'short4',
|
|
86
|
+
easing: opts.exitEasing ?? 'emphasizedAccelerate',
|
|
87
|
+
});
|
|
88
|
+
exiting.finished.then(done, () => {});
|
|
89
|
+
} else {
|
|
90
|
+
dispose();
|
|
91
|
+
dispose = null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
onCleanup(() => {
|
|
97
|
+
exiting?.cancel();
|
|
98
|
+
exiting = null;
|
|
99
|
+
dispose?.();
|
|
100
|
+
dispose = null;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return anchor;
|
|
104
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// ripple — the Material press ripple.
|
|
2
|
+
//
|
|
3
|
+
// Attach it to any positioned element (the conventions' `.control` recipe sets
|
|
4
|
+
// `position: relative`); it appends a clipped overlay and expands a wave from
|
|
5
|
+
// the pointer on press, from the center on keyboard activation. Colors ride
|
|
6
|
+
// `currentColor` at the pressed-state opacity token, so the ripple is always
|
|
7
|
+
// legible on its surface and themes with everything else. Reduced motion gets
|
|
8
|
+
// an instant press tint instead of a wave (handled inside `animate`).
|
|
9
|
+
//
|
|
10
|
+
// html`<button class="control" ref=${(el) => ripple(el, { disabled })}>…`
|
|
11
|
+
|
|
12
|
+
import { animate, settled } from './animate.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* ripple(el, { disabled, centered })
|
|
16
|
+
*
|
|
17
|
+
* disabled — boolean or thunk/signal; suppresses new ripples while truthy
|
|
18
|
+
* centered — always expand from the center (icon buttons, switches)
|
|
19
|
+
*
|
|
20
|
+
* Idempotent per element. Returns the element for ref-chaining.
|
|
21
|
+
*/
|
|
22
|
+
export function ripple(el, opts = {}) {
|
|
23
|
+
if (el.__uiRipple) return el;
|
|
24
|
+
el.__uiRipple = 1;
|
|
25
|
+
|
|
26
|
+
const holder = document.createElement('span');
|
|
27
|
+
holder.setAttribute('aria-hidden', 'true');
|
|
28
|
+
Object.assign(holder.style, {
|
|
29
|
+
position: 'absolute',
|
|
30
|
+
inset: '0',
|
|
31
|
+
overflow: 'hidden',
|
|
32
|
+
borderRadius: 'inherit',
|
|
33
|
+
pointerEvents: 'none',
|
|
34
|
+
});
|
|
35
|
+
el.append(holder);
|
|
36
|
+
|
|
37
|
+
const off = () =>
|
|
38
|
+
(typeof opts.disabled === 'function' ? !!opts.disabled() : !!opts.disabled) || el.disabled;
|
|
39
|
+
|
|
40
|
+
let release = null;
|
|
41
|
+
|
|
42
|
+
const spawn = (clientX, clientY, centered) => {
|
|
43
|
+
const rect = el.getBoundingClientRect();
|
|
44
|
+
const w = rect.width || 48;
|
|
45
|
+
const h = rect.height || 48;
|
|
46
|
+
const cx = centered ? w / 2 : clientX - rect.left;
|
|
47
|
+
const cy = centered ? h / 2 : clientY - rect.top;
|
|
48
|
+
// Soft-edge padding matches material-web: the wave slightly overruns the
|
|
49
|
+
// container so the edge never looks clipped at rest.
|
|
50
|
+
const radius = Math.hypot(Math.max(cx, w - cx), Math.max(cy, h - cy)) + 10;
|
|
51
|
+
|
|
52
|
+
const dot = document.createElement('span');
|
|
53
|
+
Object.assign(dot.style, {
|
|
54
|
+
position: 'absolute',
|
|
55
|
+
left: `${cx - radius}px`,
|
|
56
|
+
top: `${cy - radius}px`,
|
|
57
|
+
width: `${radius * 2}px`,
|
|
58
|
+
height: `${radius * 2}px`,
|
|
59
|
+
borderRadius: '50%',
|
|
60
|
+
background: 'currentColor',
|
|
61
|
+
opacity: 'var(--ui-state-pressed, 0.1)',
|
|
62
|
+
willChange: 'transform, opacity',
|
|
63
|
+
});
|
|
64
|
+
holder.append(dot);
|
|
65
|
+
|
|
66
|
+
const pressedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
67
|
+
// material-web: grow from 20% of the final size over 450ms (long1) with
|
|
68
|
+
// standard easing; stay visible at least 225ms (short4 + a beat).
|
|
69
|
+
const grow = animate(dot, [{ transform: 'scale(0.2)' }, { transform: 'scale(1)' }], {
|
|
70
|
+
duration: 'long1',
|
|
71
|
+
easing: 'standard',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
let released = false;
|
|
75
|
+
release = () => {
|
|
76
|
+
if (released) return;
|
|
77
|
+
released = true;
|
|
78
|
+
const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - pressedAt;
|
|
79
|
+
const rest = Math.max(0, 225 - elapsed);
|
|
80
|
+
const fade = () => {
|
|
81
|
+
const out = animate(dot, [{ opacity: 'var(--ui-state-pressed, 0.1)' }, { opacity: '0' }], {
|
|
82
|
+
duration: 'medium1',
|
|
83
|
+
easing: 'linear',
|
|
84
|
+
});
|
|
85
|
+
settled(out).then(() => dot.remove());
|
|
86
|
+
};
|
|
87
|
+
Promise.all([settled(grow), rest ? new Promise((r) => setTimeout(r, rest)) : null]).then(fade);
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
el.addEventListener('pointerdown', (e) => {
|
|
92
|
+
if (off() || e.button !== 0) return;
|
|
93
|
+
spawn(e.clientX, e.clientY, opts.centered);
|
|
94
|
+
const done = () => release?.();
|
|
95
|
+
window.addEventListener('pointerup', done, { once: true });
|
|
96
|
+
window.addEventListener('pointercancel', done, { once: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
el.addEventListener('keydown', (e) => {
|
|
100
|
+
if (off() || e.repeat) return;
|
|
101
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
102
|
+
spawn(0, 0, true);
|
|
103
|
+
window.addEventListener('keyup', () => release?.(), { once: true });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return el;
|
|
108
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// applyTheme — one constructed stylesheet for the whole design system.
|
|
2
|
+
//
|
|
3
|
+
// All tokens live on `:root`, so they inherit through every shadow boundary
|
|
4
|
+
// and a re-theme is a single `replaceSync` — no component re-renders, no
|
|
5
|
+
// sheet re-adoption, every element on the page updates at once.
|
|
6
|
+
//
|
|
7
|
+
// Scheme selection is layered so the same sheet serves all three modes:
|
|
8
|
+
// - light tokens on `:root` (the default)
|
|
9
|
+
// - dark tokens under `prefers-color-scheme: dark` unless the app pinned
|
|
10
|
+
// light (`<html data-ui-scheme="light">`)
|
|
11
|
+
// - dark tokens whenever the app pinned dark (`data-ui-scheme="dark"`)
|
|
12
|
+
|
|
13
|
+
import { signal, computed } from '@alacris/core';
|
|
14
|
+
import { createTheme } from './create-theme.js';
|
|
15
|
+
|
|
16
|
+
// `color-scheme` is a real CSS property riding along in the token map (it
|
|
17
|
+
// keeps scrollbars and form controls in scheme); everything else gets `--ui-`.
|
|
18
|
+
const decl = (name, value) => (name === 'color-scheme' ? name : `--ui-${name}`) + `:${value};`;
|
|
19
|
+
|
|
20
|
+
const block = (tokens) => {
|
|
21
|
+
let s = '';
|
|
22
|
+
for (const name in tokens) s += decl(name, tokens[name]);
|
|
23
|
+
return s;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Face on `:root` so native text and un-typed markup inherit the system
|
|
27
|
+
// plain typeface. Components then set a type-role shorthand on top.
|
|
28
|
+
const FACE = 'font-family:var(--ui-font-plain);font-optical-sizing:auto;font-synthesis:none;';
|
|
29
|
+
|
|
30
|
+
export function themeCss(theme) {
|
|
31
|
+
const { common, schemes } = theme;
|
|
32
|
+
return [
|
|
33
|
+
`:root{${FACE}${block(common)}${block(schemes.light)}}`,
|
|
34
|
+
`:root[data-ui-scheme="dark"]{${block(schemes.dark)}}`,
|
|
35
|
+
`@media (prefers-color-scheme: dark){:root:not([data-ui-scheme="light"]){${block(schemes.dark)}}}`,
|
|
36
|
+
].join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let sheet = null;
|
|
40
|
+
let styleEl = null;
|
|
41
|
+
|
|
42
|
+
/** The currently applied theme (a signal; null until applyTheme runs). */
|
|
43
|
+
export const activeTheme = signal(null);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Apply a theme to the document. Accepts a theme from `createTheme` or a
|
|
47
|
+
* config object (which is passed through `createTheme` for you). Calling it
|
|
48
|
+
* again rewrites the same stylesheet in place.
|
|
49
|
+
*
|
|
50
|
+
* Faces referenced by the theme are loaded automatically (a Google Fonts
|
|
51
|
+
* stylesheet for presets and `family` names). Pass `loadFonts: false` to
|
|
52
|
+
* skip — useful when the files are self-hosted or already on the page.
|
|
53
|
+
*/
|
|
54
|
+
export function applyTheme(themeOrConfig = {}) {
|
|
55
|
+
const theme = themeOrConfig.schemes ? themeOrConfig : createTheme(themeOrConfig);
|
|
56
|
+
const text = themeCss(theme);
|
|
57
|
+
if (!sheet && !styleEl) {
|
|
58
|
+
if (document.adoptedStyleSheets && typeof CSSStyleSheet.prototype.replaceSync === 'function') {
|
|
59
|
+
sheet = new CSSStyleSheet();
|
|
60
|
+
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
|
|
61
|
+
} else {
|
|
62
|
+
styleEl = document.createElement('style');
|
|
63
|
+
document.head.append(styleEl);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (sheet) sheet.replaceSync(text);
|
|
67
|
+
else styleEl.textContent = text;
|
|
68
|
+
loadThemeFonts(themeOrConfig.loadFonts === false ? { fonts: { href: null } } : theme);
|
|
69
|
+
activeTheme.set(theme);
|
|
70
|
+
return theme;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const FONT_ATTR = 'data-ui-font';
|
|
74
|
+
const PRECONNECT_ATTR = 'data-ui-font-preconnect';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Sync the document's typeface stylesheet to `theme.fonts.href`. A null href
|
|
78
|
+
* removes a previously injected link. Safe to call repeatedly; one `<link>`
|
|
79
|
+
* is reused in place. Constructed stylesheets cannot `@import`, so this is a
|
|
80
|
+
* real element — the same one `themeCss` consumers add by hand for static CSS.
|
|
81
|
+
*/
|
|
82
|
+
export function loadThemeFonts(theme, doc = document) {
|
|
83
|
+
const head = doc?.head;
|
|
84
|
+
if (!head) return;
|
|
85
|
+
const href = theme?.fonts?.href || null;
|
|
86
|
+
const existing = head.querySelector(`link[${FONT_ATTR}]`);
|
|
87
|
+
if (!href) {
|
|
88
|
+
existing?.remove();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (/fonts\.googleapis\.com|fonts\.gstatic\.com/.test(href)) ensurePreconnect(head);
|
|
92
|
+
if (existing) {
|
|
93
|
+
if (existing.getAttribute('href') !== href) existing.setAttribute('href', href);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const link = doc.createElement('link');
|
|
97
|
+
link.rel = 'stylesheet';
|
|
98
|
+
link.setAttribute('href', href);
|
|
99
|
+
link.setAttribute(FONT_ATTR, '');
|
|
100
|
+
head.append(link);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function ensurePreconnect(head) {
|
|
104
|
+
if (head.querySelector(`link[${PRECONNECT_ATTR}]`)) return;
|
|
105
|
+
for (const href of ['https://fonts.googleapis.com', 'https://fonts.gstatic.com']) {
|
|
106
|
+
const l = head.ownerDocument.createElement('link');
|
|
107
|
+
l.rel = 'preconnect';
|
|
108
|
+
l.href = href;
|
|
109
|
+
if (href.includes('gstatic')) l.crossOrigin = 'anonymous';
|
|
110
|
+
l.setAttribute(PRECONNECT_ATTR, '');
|
|
111
|
+
head.append(l);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ------------------------------------------------------------------ scheme
|
|
116
|
+
|
|
117
|
+
/** 'light' | 'dark' | 'auto' — what the app asked for. */
|
|
118
|
+
export const schemePreference = signal('auto');
|
|
119
|
+
|
|
120
|
+
const prefersDark =
|
|
121
|
+
typeof matchMedia === 'function' ? matchMedia('(prefers-color-scheme: dark)') : null;
|
|
122
|
+
const osDark = signal(!!prefersDark?.matches);
|
|
123
|
+
prefersDark?.addEventListener?.('change', (e) => osDark.set(e.matches));
|
|
124
|
+
|
|
125
|
+
/** The scheme actually in effect right now: 'light' | 'dark'. */
|
|
126
|
+
export const scheme = computed(() => {
|
|
127
|
+
const pref = schemePreference();
|
|
128
|
+
return pref === 'auto' ? (osDark() ? 'dark' : 'light') : pref;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/** Pin the scheme, or return to following the OS with 'auto'. */
|
|
132
|
+
export function setScheme(pref /* 'light' | 'dark' | 'auto' */) {
|
|
133
|
+
schemePreference.set(pref);
|
|
134
|
+
const el = document.documentElement;
|
|
135
|
+
if (pref === 'auto') el.removeAttribute('data-ui-scheme');
|
|
136
|
+
else el.setAttribute('data-ui-scheme', pref);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Flip between light and dark (leaves 'auto' by pinning the opposite). */
|
|
140
|
+
export function toggleScheme() {
|
|
141
|
+
setScheme(scheme() === 'dark' ? 'light' : 'dark');
|
|
142
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// createTheme — one config object in, complete token maps out.
|
|
2
|
+
//
|
|
3
|
+
// A theme is data: `{ config, palettes, common, schemes, fonts }`
|
|
4
|
+
// where `common` and each scheme are flat maps of token name (without the
|
|
5
|
+
// `--ui-` prefix) → CSS value, and `fonts` is `{ href, preset }` for the
|
|
6
|
+
// typeface stylesheet `applyTheme` injects. `applyTheme` turns that into one
|
|
7
|
+
// document-level stylesheet; nothing here touches the DOM, so themes can be
|
|
8
|
+
// built, diffed, serialized, or generated ahead of time.
|
|
9
|
+
|
|
10
|
+
import { makePalettes, makeScheme, COLOR_ROLES } from '../tokens/color.js';
|
|
11
|
+
import { typographyTokens, resolveTypography } from '../tokens/typography.js';
|
|
12
|
+
import {
|
|
13
|
+
shapeTokens, elevationTokens, motionTokens, spacingTokens,
|
|
14
|
+
stateTokens, focusTokens, zTokens, densityTokens,
|
|
15
|
+
} from '../tokens/system.js';
|
|
16
|
+
|
|
17
|
+
const kebab = (s) => s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* createTheme({
|
|
21
|
+
* seed: '#e8ad18', // one color → a whole scheme, or:
|
|
22
|
+
* colors: { primary, secondary, tertiary, neutral, neutralVariant,
|
|
23
|
+
* error, success, warning, info }, // explicit palette seeds
|
|
24
|
+
* typography: 'google-sans-flex' | 'google-sans' | 'roboto' | 'system'
|
|
25
|
+
* | { preset, family, brand, plain, code, scale, load },
|
|
26
|
+
* shape: { radius }, // multiplier: 0 = square, 2 = extra round
|
|
27
|
+
* motion: { scale }, // 0 = instant, 1 = default
|
|
28
|
+
* density: 0, // 0 … -2 (each step: -4px control height)
|
|
29
|
+
* overrides: { // raw token overrides, applied last
|
|
30
|
+
* common: { 'radius-md': '10px' },
|
|
31
|
+
* light: { 'color-primary': '#0b57d0' },
|
|
32
|
+
* dark: { 'color-surface': '#101014' },
|
|
33
|
+
* },
|
|
34
|
+
* })
|
|
35
|
+
*/
|
|
36
|
+
export function createTheme(config = {}) {
|
|
37
|
+
const palettes = makePalettes(config);
|
|
38
|
+
const type = resolveTypography(config.typography);
|
|
39
|
+
|
|
40
|
+
const common = {
|
|
41
|
+
...typographyTokens(type),
|
|
42
|
+
...shapeTokens(config.shape),
|
|
43
|
+
...elevationTokens(),
|
|
44
|
+
...motionTokens(config.motion),
|
|
45
|
+
...spacingTokens(),
|
|
46
|
+
...stateTokens(),
|
|
47
|
+
...focusTokens(),
|
|
48
|
+
...zTokens(),
|
|
49
|
+
...densityTokens(config),
|
|
50
|
+
...(config.overrides?.common || {}),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const schemeTokens = (scheme) => {
|
|
54
|
+
const roles = makeScheme(palettes, scheme);
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const role of COLOR_ROLES) out[`color-${kebab(role)}`] = roles[role];
|
|
57
|
+
// Deepen shadows on dark surfaces, keep native widgets in scheme.
|
|
58
|
+
out['shadow-rgb'] = '0 0 0';
|
|
59
|
+
out['color-scheme'] = scheme;
|
|
60
|
+
return { ...out, ...(config.overrides?.[scheme] || {}) };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
config,
|
|
65
|
+
palettes,
|
|
66
|
+
common,
|
|
67
|
+
schemes: { light: schemeTokens('light'), dark: schemeTokens('dark') },
|
|
68
|
+
fonts: { href: type.href, preset: type.preset },
|
|
69
|
+
};
|
|
70
|
+
}
|