actual-css 0.13.0 → 0.14.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/CHANGELOG.md +67 -0
- package/README.md +8 -8
- package/components.json +116 -10
- package/dist/actual.full.css +652 -77
- package/dist/actual.full.js +1 -1
- package/dist/actual.full.min.css +1 -1
- package/docs/design-notes/widget-primitives.md +4 -0
- package/docs/navigation.json +2 -0
- package/docs/pages/components/alert.md +12 -2
- package/docs/pages/components/button.md +3 -2
- package/docs/pages/components/list.md +6 -3
- package/docs/pages/components/segmented.md +83 -0
- package/docs/pages/components/steps.md +4 -2
- package/docs/pages/enhancements/flyout.md +9 -3
- package/docs/pages/enhancements/overview.md +2 -1
- package/docs/pages/enhancements/tabs.md +7 -2
- package/docs/pages/enhancements/tooltip.md +3 -1
- package/docs/pages/examples/overview.md +5 -3
- package/docs/pages/forms/color-swatches.md +131 -0
- package/docs/pages/forms/combobox.md +2 -2
- package/docs/pages/forms/controls.md +6 -3
- package/docs/pages/forms/joined-controls.md +6 -2
- package/docs/pages/forms/overview.md +1 -0
- package/docs/pages/forms/switch.md +4 -2
- package/docs/pages/foundations/theming.md +1 -1
- package/docs/pages/foundations/tokens.md +54 -23
- package/docs/pages/guides/design-tools.md +13 -7
- package/docs/pages/guides/introduction.md +1 -1
- package/docs/pages/layout/scroll-snap.md +4 -2
- package/package.json +5 -4
- package/reserved-classes.json +3 -0
- package/src/cli/actual-css.js +3 -3
- package/src/css/components/alert.css +65 -14
- package/src/css/components/app-nav.css +17 -9
- package/src/css/components/badge.css +23 -9
- package/src/css/components/button.css +17 -2
- package/src/css/components/close.css +6 -3
- package/src/css/components/index.css +1 -0
- package/src/css/components/list.css +18 -3
- package/src/css/components/navbar.css +5 -2
- package/src/css/components/segmented.css +186 -0
- package/src/css/components/steps.css +6 -3
- package/src/css/components/tab.css +1 -1
- package/src/css/core/focus.css +3 -0
- package/src/css/core/tokens.css +20 -4
- package/src/css/core/variants.css +16 -5
- package/src/css/forms/color-swatch.css +163 -0
- package/src/css/forms/form-actions.css +9 -0
- package/src/css/forms/index.css +1 -0
- package/src/css/forms/native.css +50 -20
- package/src/css/forms/switch.css +5 -2
- package/src/css/layout/scroll-snap.css +30 -0
- package/src/css/layout/topbar.css +10 -0
- package/src/js/enhance.js +14 -0
- package/src/js/floating-space.js +26 -0
- package/src/js/flyout.js +3 -1
- package/src/js/focus.js +16 -1
- package/src/js/parse-config.js +5 -2
- package/src/js/surface.js +10 -0
- package/src/js/tooltip.js +2 -2
- package/src/tooling/color.js +14 -1
- package/src/tooling/css-bundle.js +0 -5
- package/src/tooling/design-components.js +9 -5
- package/src/tooling/design-export.js +8 -0
- package/src/tooling/design-tokens.js +163 -43
- package/src/tooling/theme-contrast.js +151 -22
|
@@ -13,16 +13,36 @@
|
|
|
13
13
|
* --scroll-snap-align
|
|
14
14
|
* --scroll-snap-item-size
|
|
15
15
|
* --scroll-snap-padding
|
|
16
|
+
*
|
|
17
|
+
* Internal:
|
|
18
|
+
* --scroll-snap-focus-bleed
|
|
16
19
|
*/
|
|
17
20
|
.scroll-snap {
|
|
18
21
|
--scroll-snap-gap: var(--gap);
|
|
19
22
|
--scroll-snap-align: start;
|
|
20
23
|
--scroll-snap-item-size: auto;
|
|
24
|
+
/* Declared here, not as a :root alias: an inherited alias arrives already
|
|
25
|
+
computed, so a --focus-ring-width override on the rail would not reach it. */
|
|
26
|
+
--scroll-snap-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
|
|
21
27
|
|
|
22
28
|
display: flex;
|
|
23
29
|
flex-direction: row;
|
|
24
30
|
gap: var(--scroll-snap-gap);
|
|
31
|
+
/* Both axes clip: `auto` on one computes the other to `auto`, so a ring on
|
|
32
|
+
the top or bottom item would be lost too. Every side reserves the ring's
|
|
33
|
+
own width; the block reserve returns to the flow through a negative
|
|
34
|
+
margin, while the inline reserve stays as the rail's focus gutter.
|
|
35
|
+
Bleeding the inline reserve outward instead would make the border box
|
|
36
|
+
`containing block + 2 * reserve` wide, which at the viewport edge grows
|
|
37
|
+
the page: a rail cannot be flush with arbitrary surrounding content and
|
|
38
|
+
free of page overflow at once. */
|
|
39
|
+
margin-block: calc(var(--scroll-snap-focus-bleed) * -1);
|
|
40
|
+
padding-block: var(--scroll-snap-focus-bleed);
|
|
41
|
+
padding-inline: var(--scroll-snap-focus-bleed);
|
|
25
42
|
overflow-x: auto;
|
|
43
|
+
/* One scroll axis, stated: a fractional content height could otherwise raise
|
|
44
|
+
a phantom vertical scrollbar under one font or zoom step. */
|
|
45
|
+
overflow-y: hidden;
|
|
26
46
|
}
|
|
27
47
|
|
|
28
48
|
.scroll-snap > * {
|
|
@@ -32,7 +52,17 @@
|
|
|
32
52
|
@supports (scroll-snap-type: x mandatory) {
|
|
33
53
|
.scroll-snap {
|
|
34
54
|
overscroll-behavior-inline: contain;
|
|
55
|
+
/* Fallback only, never the ring's protector: padding-inline above already
|
|
56
|
+
guarantees paint. A unitless `0` drops the clamp below but keeps this
|
|
57
|
+
declaration, so the worst case is a snapped item flush with the gutter,
|
|
58
|
+
not a clipped ring. */
|
|
35
59
|
scroll-padding-inline: var(--scroll-snap-padding, var(--scroll-snap-gap));
|
|
60
|
+
/* A conforming <length> stays at least the focus reserve, so scroll and
|
|
61
|
+
snap stop with the gutter's air. */
|
|
62
|
+
scroll-padding-inline: max(
|
|
63
|
+
var(--scroll-snap-padding, var(--scroll-snap-gap)),
|
|
64
|
+
var(--scroll-snap-focus-bleed)
|
|
65
|
+
);
|
|
36
66
|
/* x then inline: an engine that lacks the logical keyword keeps x.
|
|
37
67
|
proximity is the default, so a short rail cannot trap the pointer. */
|
|
38
68
|
scroll-snap-type: x proximity;
|
|
@@ -45,3 +45,13 @@
|
|
|
45
45
|
-webkit-backdrop-filter: blur(0.75rem);
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
/* A reduce override, not a no-preference gate: a browser without the feature
|
|
50
|
+
evaluates it false and keeps the frosted bar instead of losing it. */
|
|
51
|
+
@media (prefers-reduced-transparency: reduce) {
|
|
52
|
+
.topbar {
|
|
53
|
+
background: var(--surface);
|
|
54
|
+
backdrop-filter: none;
|
|
55
|
+
-webkit-backdrop-filter: none;
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/js/enhance.js
CHANGED
|
@@ -31,6 +31,20 @@
|
|
|
31
31
|
const registries = new WeakMap();
|
|
32
32
|
const ownedNames = new WeakMap();
|
|
33
33
|
|
|
34
|
+
// Each evaluation owns its registries, so a second copy (the dist bundle plus
|
|
35
|
+
// source modules, or two bundles) registers every behavior twice and fails
|
|
36
|
+
// silently — a flyout opens then closes. Every built-in imports this module,
|
|
37
|
+
// so the marker catches any duplicated runtime once, here.
|
|
38
|
+
const RUNTIME = Symbol.for("actual-css.runtime");
|
|
39
|
+
if (typeof document !== "undefined") {
|
|
40
|
+
if (globalThis[RUNTIME]) {
|
|
41
|
+
console.warn(
|
|
42
|
+
"actual-css: a second copy of the JavaScript runtime is loaded. Load the full bundle or the source modules, never both.",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
globalThis[RUNTIME] = true;
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
// Node type constants as literals, not the realm's Node global, so nodes from
|
|
35
49
|
// another document/window compare correctly.
|
|
36
50
|
const ELEMENT_NODE = 1;
|
package/src/js/floating-space.js
CHANGED
|
@@ -29,6 +29,32 @@ function isViewportAnchored(anchor) {
|
|
|
29
29
|
return false;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// Mirrors the placements @lekoala/floating accepts; it throws on any other
|
|
33
|
+
// value, and from an autoUpdate callback that throw would repeat on every
|
|
34
|
+
// scroll. Markup is validated here instead, so a typo warns once.
|
|
35
|
+
const PLACEMENTS = new Set(
|
|
36
|
+
["top", "bottom", "left", "right"].flatMap((side) => [side, `${side}-start`, `${side}-end`]),
|
|
37
|
+
);
|
|
38
|
+
const warnedPlacements = new WeakSet();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The author's placement attribute, or null when absent or unknown.
|
|
42
|
+
*
|
|
43
|
+
* @param {Element | null | undefined} el
|
|
44
|
+
* @param {string} attribute
|
|
45
|
+
* @returns {string | null}
|
|
46
|
+
*/
|
|
47
|
+
export function readPlacement(el, attribute) {
|
|
48
|
+
const value = el?.getAttribute(attribute);
|
|
49
|
+
if (!value) return null;
|
|
50
|
+
if (PLACEMENTS.has(value)) return value;
|
|
51
|
+
if (!warnedPlacements.has(el)) {
|
|
52
|
+
warnedPlacements.add(el);
|
|
53
|
+
console.warn(`Unknown ${attribute}="${value}" ignored`, el);
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
32
58
|
/**
|
|
33
59
|
* @param {Element | null | undefined} anchor
|
|
34
60
|
* @returns {{ space: "viewport" | "document", position: "fixed" | "absolute" }}
|
package/src/js/flyout.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { registerEnhancement } from "./enhance.js";
|
|
2
|
+
import { readPlacement } from "./floating-space.js";
|
|
2
3
|
import { focusFirstDescendant } from "./focus.js";
|
|
3
4
|
import { shouldIgnoreKey } from "./keys.js";
|
|
4
5
|
import { connectMenu, focusFirstMenuItem, focusLastMenuItem } from "./menu.js";
|
|
@@ -9,7 +10,8 @@ const triggerMap = new WeakMap();
|
|
|
9
10
|
function readFlyoutOptions(panel) {
|
|
10
11
|
const opts = { autoClose: "true" };
|
|
11
12
|
const ds = panel.dataset;
|
|
12
|
-
|
|
13
|
+
const placement = readPlacement(panel, "data-flyout-placement");
|
|
14
|
+
if (placement) opts.placement = placement;
|
|
13
15
|
if (ds.flyoutDistance != null) {
|
|
14
16
|
const d = Number.parseFloat(ds.flyoutDistance);
|
|
15
17
|
if (Number.isFinite(d)) opts.distance = d;
|
package/src/js/focus.js
CHANGED
|
@@ -44,8 +44,23 @@ export function getFocusable(root) {
|
|
|
44
44
|
return [...root.querySelectorAll(FOCUSABLE_SELECTOR)].filter(isFocusable);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/* Tab reaches a radio group at its checked radio, or at its first one when none
|
|
48
|
+
is checked; the other radios are arrow-key stops only. Entering a panel on
|
|
49
|
+
an unchecked radio put focus on a value the user had not picked. */
|
|
50
|
+
function isRadioTabStop(el) {
|
|
51
|
+
if (el.type !== "radio" || el.checked || !el.name) return true;
|
|
52
|
+
// form.elements also holds controls associated through form="…".
|
|
53
|
+
const candidates = el.form
|
|
54
|
+
? [...el.form.elements]
|
|
55
|
+
: [...el.getRootNode().querySelectorAll('input[type="radio"]')];
|
|
56
|
+
return !candidates.some(
|
|
57
|
+
(radio) =>
|
|
58
|
+
radio.type === "radio" && radio.name === el.name && radio.form === el.form && radio.checked,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
export function focusFirstDescendant(root) {
|
|
48
|
-
const target = getFocusable(root)[0];
|
|
63
|
+
const target = getFocusable(root).filter(isRadioTabStop)[0];
|
|
49
64
|
target?.focus();
|
|
50
65
|
return !!target;
|
|
51
66
|
}
|
package/src/js/parse-config.js
CHANGED
|
@@ -11,10 +11,13 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
function relax(source) {
|
|
14
|
+
// Double-quoted strings are matched first and copied verbatim, so text
|
|
15
|
+
// such as `http:` inside them is never mistaken for an unquoted key.
|
|
14
16
|
return source.replace(
|
|
15
|
-
/([a-zA-Z_$][\w$-]*)\s*:|'((?:\\'|[^'])*)'/g,
|
|
16
|
-
(
|
|
17
|
+
/"(?:\\.|[^"\\])*"|([a-zA-Z_$][\w$-]*)\s*:|'((?:\\'|[^'])*)'/g,
|
|
18
|
+
(match, key, stringContent) => {
|
|
17
19
|
if (key) return `"${key}":`;
|
|
20
|
+
if (stringContent === undefined) return match;
|
|
18
21
|
const unescaped = stringContent.replace(/\\'/g, "'");
|
|
19
22
|
const escaped = unescaped.replace(/"/g, '\\"');
|
|
20
23
|
return `"${escaped}"`;
|
package/src/js/surface.js
CHANGED
|
@@ -386,8 +386,17 @@ export function disconnectSurface(menu) {
|
|
|
386
386
|
}
|
|
387
387
|
}
|
|
388
388
|
|
|
389
|
+
/* Arrow keys in a radio group check the next radio by dispatching a click
|
|
390
|
+
(detail 0, no pointer). That click is navigation, not a pick: closing on it
|
|
391
|
+
shut a palette on the first arrow press, before the user could reach the
|
|
392
|
+
color they wanted. Space dispatches the same click and is left alone too. */
|
|
393
|
+
function isKeyboardRadioMove(e) {
|
|
394
|
+
return e.detail === 0 && e.target?.matches?.('input[type="radio"]') === true;
|
|
395
|
+
}
|
|
396
|
+
|
|
389
397
|
function onDocumentClick(e) {
|
|
390
398
|
const path = e.composedPath();
|
|
399
|
+
const radioMove = isKeyboardRadioMove(e);
|
|
391
400
|
|
|
392
401
|
for (const menu of openSurfaces) {
|
|
393
402
|
if (menu.ownerDocument !== e.currentTarget) continue;
|
|
@@ -396,6 +405,7 @@ function onDocumentClick(e) {
|
|
|
396
405
|
|
|
397
406
|
const menuIndex = path.indexOf(menu);
|
|
398
407
|
const isInside = menuIndex >= 0;
|
|
408
|
+
if (isInside && radioMove) continue;
|
|
399
409
|
const hasCloseTrigger =
|
|
400
410
|
isInside &&
|
|
401
411
|
path
|
package/src/js/tooltip.js
CHANGED
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
import { autoUpdate, reposition } from "@lekoala/floating";
|
|
52
52
|
import enhance from "./enhance.js";
|
|
53
53
|
import { registerEscapeDismissal } from "./escape.js";
|
|
54
|
-
import { positionModeFor } from "./floating-space.js";
|
|
54
|
+
import { positionModeFor, readPlacement } from "./floating-space.js";
|
|
55
55
|
import { CLASSES } from "./selectors.js";
|
|
56
56
|
|
|
57
57
|
const SHOW_DELAY_MS = 150;
|
|
@@ -144,7 +144,7 @@ export function isTooltipVisible(tip) {
|
|
|
144
144
|
* the tip's own placement never enters into it.
|
|
145
145
|
*/
|
|
146
146
|
function placementFor(ref) {
|
|
147
|
-
return ref
|
|
147
|
+
return readPlacement(ref, "data-tooltip-placement") ?? "top";
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
function hasOption(trigger, name) {
|
package/src/tooling/color.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* `const rgba = ${RASTERIZE};`. getComputedStyle returns color-mix() and
|
|
11
11
|
* light-dark() results as oklab() or color(), so a 1x1 canvas turns any
|
|
12
12
|
* computed color into sRGB bytes. Alpha is kept so contrast() can refuse a
|
|
13
|
-
* translucent color instead of measuring it as if it were opaque
|
|
13
|
+
* translucent color instead of measuring it as if it were opaque; composite()
|
|
14
|
+
* resolves it over its backdrop first.
|
|
14
15
|
*/
|
|
15
16
|
export const RASTERIZE = `(() => {
|
|
16
17
|
const canvas = Object.assign(document.createElement("canvas"), { width: 1, height: 1 });
|
|
@@ -46,6 +47,18 @@ export function contrast(a, b) {
|
|
|
46
47
|
return (hi + 0.05) / (lo + 0.05);
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/* `top` painted over `base`, the way the page composites a fill: source-over
|
|
51
|
+
on gamma-encoded sRGB bytes. The result is opaque when `base` is, so a
|
|
52
|
+
translucent soft fill becomes measurable once its backdrop is known. */
|
|
53
|
+
export function composite(top, base) {
|
|
54
|
+
const a = (top[3] ?? 255) / 255;
|
|
55
|
+
const b = (base[3] ?? 255) / 255;
|
|
56
|
+
const alpha = a + b * (1 - a);
|
|
57
|
+
if (alpha === 0) return [0, 0, 0, 0];
|
|
58
|
+
const channel = (i) => Math.round((top[i] * a + base[i] * b * (1 - a)) / alpha);
|
|
59
|
+
return [channel(0), channel(1), channel(2), Math.round(alpha * 255)];
|
|
60
|
+
}
|
|
61
|
+
|
|
49
62
|
/* OKLCH of an sRGB color: L 0-1, C chroma, h degrees. */
|
|
50
63
|
export function oklch([r, g, b]) {
|
|
51
64
|
const [lr, lg, lb] = [r, g, b].map(toLinear);
|
|
@@ -298,11 +298,6 @@ export function bundledCssIssues(css) {
|
|
|
298
298
|
const issues = [];
|
|
299
299
|
|
|
300
300
|
if (css.includes("@import")) issues.push("contains unresolved @import");
|
|
301
|
-
if (css.includes("--lightningcss")) issues.push("contains --lightningcss-* variables");
|
|
302
|
-
if (/color-mix\(in srgb/.test(css)) issues.push("contains color-mix(in srgb, ...)");
|
|
303
|
-
if (/@media[^{]*\(\s*width\s*[<>]=/.test(css)) {
|
|
304
|
-
issues.push("contains transpiled media query range syntax");
|
|
305
|
-
}
|
|
306
301
|
|
|
307
302
|
return issues;
|
|
308
303
|
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* padding, height, type — about 1,800 bindings over 221 variants) froze
|
|
16
16
|
* Penpot, and a theme's own lengths already arrive through the measurement.
|
|
17
17
|
*/
|
|
18
|
-
import { hexOf, inPage, parseSrgb } from "./design-tokens.js";
|
|
18
|
+
import { hexOf, hexRgbOf, inPage, parseSrgb } from "./design-tokens.js";
|
|
19
19
|
|
|
20
20
|
/* Every list starts with its default: the first combination is the one a
|
|
21
21
|
design tool picks for a new instance. */
|
|
@@ -214,11 +214,15 @@ export async function measureComponents({ css, resolved, linked, components = CO
|
|
|
214
214
|
modes.forEach((mode, m) => {
|
|
215
215
|
const known = recipes[mode][token];
|
|
216
216
|
// A recipe name carries no size and no component: if two of them
|
|
217
|
-
// ever paint different colors, the name has to grow a dimension.
|
|
218
|
-
|
|
219
|
-
|
|
217
|
+
// ever paint different colors, the name has to grow a dimension. The
|
|
218
|
+
// comparison is on the CSS form (alpha included), the stored hex is the
|
|
219
|
+
// 6-digit DTCG form.
|
|
220
|
+
if (known && hexOf(known.color) !== hexes[m]) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`Recipe ${token} paints ${hexOf(known.color)} and ${hexes[m]} in ${mode}`,
|
|
223
|
+
);
|
|
220
224
|
}
|
|
221
|
-
recipes[mode][token] = { color: colors[m], hex:
|
|
225
|
+
recipes[mode][token] = { color: colors[m], hex: hexRgbOf(colors[m]) };
|
|
222
226
|
});
|
|
223
227
|
}
|
|
224
228
|
const [first] = colors;
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
linkTokens,
|
|
15
15
|
resolveTokens,
|
|
16
16
|
themeNameOf,
|
|
17
|
+
toDtcg,
|
|
17
18
|
toFigma,
|
|
18
19
|
toPenpot,
|
|
19
20
|
} from "./design-tokens.js";
|
|
@@ -24,6 +25,7 @@ const PLUGIN = fileURLToPath(new URL("./penpot-plugin/", import.meta.url));
|
|
|
24
25
|
/*
|
|
25
26
|
* Writes, under `out`:
|
|
26
27
|
* figma/foundations.tokens.json, figma/<scheme>.tokens.json
|
|
28
|
+
* dtcg/foundations.tokens.json, dtcg/<scheme>.tokens.json
|
|
27
29
|
* penpot/tokens.json
|
|
28
30
|
* penpot/plugin/ (manifest.json, plugin.js, ui.html, spec.json)
|
|
29
31
|
* Without `themeFile`, exports the default theme. `name` overrides the
|
|
@@ -49,6 +51,12 @@ export async function exportDesign({ themeFile, name, out }) {
|
|
|
49
51
|
doc,
|
|
50
52
|
]),
|
|
51
53
|
),
|
|
54
|
+
...Object.fromEntries(
|
|
55
|
+
Object.entries(toDtcg(linked, recipes)).map(([file, doc]) => [
|
|
56
|
+
`dtcg/${file}.tokens.json`,
|
|
57
|
+
doc,
|
|
58
|
+
]),
|
|
59
|
+
),
|
|
52
60
|
"penpot/tokens.json": toPenpot(linked, recipes),
|
|
53
61
|
"penpot/plugin/spec.json": spec,
|
|
54
62
|
};
|