@raylang/elements 0.0.0-stage → 0.4.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/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +12 -2
- package/base.css +111 -0
- package/components/accordion/accordion.js +197 -0
- package/components/alert/alert.js +112 -0
- package/components/app-bar/app-bar.js +168 -0
- package/components/app-shell/app-shell.js +198 -0
- package/components/aspect-ratio/aspect-ratio.js +59 -0
- package/components/attachment/attachment.js +333 -0
- package/components/avatar/avatar.js +96 -0
- package/components/badge/badge.js +63 -0
- package/components/blockquote/blockquote.js +128 -0
- package/components/bottom-nav/bottom-nav.js +309 -0
- package/components/breadcrumbs/breadcrumbs.js +175 -0
- package/components/button/button.js +283 -0
- package/components/button-group/button-group.js +72 -0
- package/components/card/card.js +73 -0
- package/components/carousel/carousel.js +435 -0
- package/components/center/center.js +26 -0
- package/components/checkbox/checkbox.js +152 -0
- package/components/checkbox-group/checkbox-group.js +152 -0
- package/components/code/code.js +574 -0
- package/components/color-picker/color-picker.js +775 -0
- package/components/combobox/combobox.js +563 -0
- package/components/command-palette/command-palette.js +510 -0
- package/components/container/container.js +41 -0
- package/components/date-picker/date-picker.js +594 -0
- package/components/dialog/dialog.js +329 -0
- package/components/drawer/drawer.js +139 -0
- package/components/empty-state/empty-state.js +66 -0
- package/components/file-upload/file-upload.js +589 -0
- package/components/grid/grid.js +96 -0
- package/components/group/group.js +40 -0
- package/components/icon/icon.js +58 -0
- package/components/icon-button/icon-button.js +30 -0
- package/components/indicator/indicator.js +112 -0
- package/components/input/input.js +85 -0
- package/components/kbd/kbd.js +60 -0
- package/components/menu/menu.js +296 -0
- package/components/nav-link/nav-link.js +159 -0
- package/components/notification/notification.js +591 -0
- package/components/number-input/number-input.js +241 -0
- package/components/pagination/pagination.js +194 -0
- package/components/password-input/password-input.js +85 -0
- package/components/path-input/path-input.js +137 -0
- package/components/popover/popover.js +85 -0
- package/components/progress/progress.js +109 -0
- package/components/radio/radio.js +253 -0
- package/components/scroll-area/scroll-area.js +158 -0
- package/components/search/search.js +157 -0
- package/components/segmented-control/segmented-control.js +190 -0
- package/components/select/select.js +506 -0
- package/components/separator/separator.js +58 -0
- package/components/sidebar/sidebar.js +252 -0
- package/components/skeleton/skeleton.js +90 -0
- package/components/slider/slider.js +155 -0
- package/components/spinner/spinner.js +39 -0
- package/components/split/split.js +221 -0
- package/components/stack/stack.js +46 -0
- package/components/stat/stat.js +79 -0
- package/components/statusbar/statusbar.js +110 -0
- package/components/stepper/stepper.js +239 -0
- package/components/switch/switch.js +45 -0
- package/components/table/table.js +328 -0
- package/components/tabs/tabs.js +224 -0
- package/components/textarea/textarea.js +105 -0
- package/components/theme-toggle/theme-toggle.js +78 -0
- package/components/timeline/timeline.js +150 -0
- package/components/toggle/toggle.js +130 -0
- package/components/toggle-group/toggle-group.js +216 -0
- package/components/tooltip/tooltip.js +101 -0
- package/core/charts.js +107 -0
- package/core/color.js +110 -0
- package/core/core.js +556 -0
- package/core/dismiss.js +69 -0
- package/core/floating.js +195 -0
- package/core/focus.js +70 -0
- package/core/form.js +235 -0
- package/core/host.js +200 -0
- package/core/hotkey.js +40 -0
- package/core/icons.js +104 -0
- package/core/motion.js +73 -0
- package/core/position.js +91 -0
- package/core/raylang.js +167 -0
- package/core/scroll.js +58 -0
- package/core/styles.js +211 -0
- package/core.js +16 -0
- package/custom-elements.json +18545 -0
- package/index.js +74 -0
- package/package.json +312 -4
- package/ray-elements.d.ts +2570 -0
- package/tokens.css +796 -0
- package/tokens.json +2083 -0
- package/vscode.html-custom-data.json +5499 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// <ray-toggle>: a two-state button (aria-pressed), like shadcn's Toggle — bold
|
|
2
|
+
// in a toolbar, "mute", "favorite". A RayButton, so sizes, radius, color,
|
|
3
|
+
// icons in the left/right slots and ray-button-group all work.
|
|
4
|
+
//
|
|
5
|
+
// The variant describes how "on" looks; "off" is always neutral, so the two
|
|
6
|
+
// states differ by more than color (WCAG 1.4.1):
|
|
7
|
+
// subtle — off: transparent; on: tinted fill (default)
|
|
8
|
+
// outline — off: neutral border; on: colored border + tint
|
|
9
|
+
// light — off: muted fill; on: colored tint
|
|
10
|
+
// filled — off: neutral surface with border; on: solid color
|
|
11
|
+
// raised — off: a neutral key standing up; on: latched down and lit
|
|
12
|
+
// Button variants left out: default (no "on" look of its own), transparent
|
|
13
|
+
// (would change only the text color) and gradient (a second filled).
|
|
14
|
+
|
|
15
|
+
import { css, define } from "../../core/core.js";
|
|
16
|
+
import { RayButton } from "../button/button.js";
|
|
17
|
+
|
|
18
|
+
const styles = css`
|
|
19
|
+
:host([square]) .button { width: var(--_h); padding: 0; }
|
|
20
|
+
:host([square]) .label { font-size: calc(var(--_fs) * 1.15); }
|
|
21
|
+
::slotted(svg) { width: 1.15em; height: 1.15em; }
|
|
22
|
+
|
|
23
|
+
/* Base of the drawn variants, at the specificity of one class so each
|
|
24
|
+
variant's own off look below wins. */
|
|
25
|
+
.button:where([data-toggle]:not([data-toggle="raised"])) { color: var(--ray-foreground); background: transparent; }
|
|
26
|
+
.button[data-toggle] ::slotted(ray-icon), .button[data-toggle] ::slotted(svg) { opacity: 0.85; }
|
|
27
|
+
.button[data-toggle][aria-pressed="true"] ::slotted(*) { opacity: 1; }
|
|
28
|
+
|
|
29
|
+
/* subtle */
|
|
30
|
+
.button[data-toggle="subtle"]:not([data-disabled]):hover { background: var(--ray-default-hover); }
|
|
31
|
+
.button[data-toggle="subtle"][aria-pressed="true"] { background: var(--_light); color: var(--_light-color); }
|
|
32
|
+
.button[data-toggle="subtle"][aria-pressed="true"]:not([data-disabled]):hover { background: var(--_light-hover); }
|
|
33
|
+
|
|
34
|
+
/* outline */
|
|
35
|
+
.button[data-toggle="outline"] { border-color: var(--ray-border); }
|
|
36
|
+
.button[data-toggle="outline"]:not([data-disabled]):hover { background: var(--ray-default-hover); }
|
|
37
|
+
.button[data-toggle="outline"][aria-pressed="true"] { border-color: color-mix(in srgb, var(--_outline) 65%, var(--ray-border)); background: var(--_light); color: var(--_light-color); }
|
|
38
|
+
.button[data-toggle="outline"][aria-pressed="true"]:not([data-disabled]):hover { background: var(--_light-hover); }
|
|
39
|
+
|
|
40
|
+
/* light */
|
|
41
|
+
.button[data-toggle="light"] { background: var(--ray-muted); }
|
|
42
|
+
.button[data-toggle="light"]:not([data-disabled]):hover { background: var(--ray-default-hover); }
|
|
43
|
+
.button[data-toggle="light"][aria-pressed="true"] { background: var(--_light); color: var(--_light-color); }
|
|
44
|
+
.button[data-toggle="light"][aria-pressed="true"]:not([data-disabled]):hover { background: var(--_light-hover); }
|
|
45
|
+
|
|
46
|
+
/* filled */
|
|
47
|
+
.button[data-toggle="filled"] { background: var(--ray-default); border-color: var(--ray-border); }
|
|
48
|
+
.button[data-toggle="filled"]:not([data-disabled]):hover { background: var(--ray-default-hover); }
|
|
49
|
+
.button[data-toggle="filled"][aria-pressed="true"] { background: var(--_filled); color: var(--_contrast); border-color: transparent; }
|
|
50
|
+
.button[data-toggle="filled"][aria-pressed="true"]:not([data-disabled]):hover { background: var(--_filled-hover); }
|
|
51
|
+
|
|
52
|
+
/* Disabled keeps showing its state, dimmed. */
|
|
53
|
+
.button[data-toggle][data-disabled]:not([data-toggle="raised"]) { opacity: 0.5; }
|
|
54
|
+
|
|
55
|
+
/* raised: off is an unlit neutral key (ray-kbd's colors) ... */
|
|
56
|
+
.button[data-toggle="raised"]:not([aria-pressed="true"]) {
|
|
57
|
+
--_face: var(--ray-raised);
|
|
58
|
+
--_face-text: var(--ray-foreground);
|
|
59
|
+
--_rim: light-dark(color-mix(in srgb, var(--_face), black 12%), color-mix(in srgb, var(--_face) 82%, var(--ray-background)));
|
|
60
|
+
--_edge: light-dark(color-mix(in srgb, var(--_face), black 24%), color-mix(in srgb, var(--_face) 62%, var(--ray-background)));
|
|
61
|
+
/* ray-kbd's top highlight: a strong one reads as an edge on top in dark. */
|
|
62
|
+
box-shadow:
|
|
63
|
+
0 var(--_depth) 0 var(--_edge),
|
|
64
|
+
0 calc(var(--_depth) + 1px) 6px -1px rgb(0 0 0 / 0.28),
|
|
65
|
+
inset 0 1px 0 light-dark(rgb(255 255 255 / 0.7), rgb(255 255 255 / 0.07));
|
|
66
|
+
}
|
|
67
|
+
/* ... and on stays down, lit in its color, like a latching switch. */
|
|
68
|
+
.button[data-toggle="raised"][aria-pressed="true"]:not([data-disabled]) {
|
|
69
|
+
transform: translateY(var(--_depth));
|
|
70
|
+
/* Sunk: shaded under the top rim. In dark a touch deeper (a light
|
|
71
|
+
shadow does not show on the color there); light keeps the original. */
|
|
72
|
+
box-shadow:
|
|
73
|
+
0 0 0 var(--_edge),
|
|
74
|
+
inset 0 1px 3px light-dark(rgb(0 0 0 / 0.28), rgb(0 0 0 / 0.4));
|
|
75
|
+
border-top-color: light-dark(var(--_rim), color-mix(in srgb, var(--_face), black 24%));
|
|
76
|
+
}
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
const VARIANTS = ["subtle", "outline", "light", "filled", "raised"];
|
|
80
|
+
|
|
81
|
+
export class RayToggle extends RayButton {
|
|
82
|
+
static properties = {
|
|
83
|
+
...RayButton.properties,
|
|
84
|
+
variant: { type: String, reflect: true, default: "subtle" },
|
|
85
|
+
pressed: { type: Boolean, reflect: true },
|
|
86
|
+
square: { type: Boolean, reflect: true },
|
|
87
|
+
label: { type: String, reflect: true },
|
|
88
|
+
};
|
|
89
|
+
static styles = [...RayButton.styles, styles];
|
|
90
|
+
|
|
91
|
+
#initial = null;
|
|
92
|
+
|
|
93
|
+
constructor() {
|
|
94
|
+
super();
|
|
95
|
+
// Clicks reach the host after the inner button's own handler, which stops
|
|
96
|
+
// them while disabled or loading.
|
|
97
|
+
this.addEventListener("click", () => {
|
|
98
|
+
if (this.disabled || this.loading || this._fieldsetDisabled) return;
|
|
99
|
+
this.pressed = !this.pressed;
|
|
100
|
+
this.dispatchEvent(new Event("change", { bubbles: true }));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
connectedCallback() {
|
|
105
|
+
this.#initial ??= this.hasAttribute("pressed");
|
|
106
|
+
super.connectedCallback();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
formResetCallback() {
|
|
110
|
+
this.pressed = this.#initial ?? false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
update() {
|
|
114
|
+
super.update();
|
|
115
|
+
const el = this._el;
|
|
116
|
+
const variant = VARIANTS.includes(this.variant) ? this.variant : "subtle";
|
|
117
|
+
// raised reuses ray-button's key; the others are drawn here.
|
|
118
|
+
el.dataset.variant = variant === "raised" ? "raised" : "toggle";
|
|
119
|
+
el.dataset.toggle = variant;
|
|
120
|
+
el.setAttribute("aria-pressed", String(Boolean(this.pressed)));
|
|
121
|
+
if (this.label) {
|
|
122
|
+
el.setAttribute("aria-label", this.label);
|
|
123
|
+
el.title = this.label;
|
|
124
|
+
}
|
|
125
|
+
// Submitted like a checkbox: value (default "on") under name while pressed.
|
|
126
|
+
this.internals.setFormValue(this.pressed && this.name ? (this.value ?? "on") : null);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
define("ray-toggle", RayToggle);
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// <ray-toggle-group>: a set of <ray-toggle> with one shared value (shadcn's
|
|
2
|
+
// ToggleGroup). type="single": at most one on (text alignment, a view mode);
|
|
3
|
+
// type="multiple": any number (bold / italic / underline).
|
|
4
|
+
//
|
|
5
|
+
// A ray-button-group, so the toggles are joined (corners squared, borders
|
|
6
|
+
// overlapped); `spaced` keeps them apart. variant, color, size and square on
|
|
7
|
+
// the group reach every toggle that does not set its own. Arrow keys move
|
|
8
|
+
// between toggles (one tab stop, the selected one); Space/Enter flips the
|
|
9
|
+
// focused one. Each toggle's `value` (else its text) is its key.
|
|
10
|
+
//
|
|
11
|
+
// <ray-toggle-group type="single" value="left" label="Alignment">
|
|
12
|
+
// <ray-toggle value="left">Left</ray-toggle> …
|
|
13
|
+
// </ray-toggle-group>
|
|
14
|
+
|
|
15
|
+
import { css, define } from "../../core/core.js";
|
|
16
|
+
import { RayButtonGroup } from "../button-group/button-group.js";
|
|
17
|
+
import "../toggle/toggle.js";
|
|
18
|
+
|
|
19
|
+
const styles = css`
|
|
20
|
+
/* spaced: apart, each toggle with its own corners. */
|
|
21
|
+
:host([spaced]) { gap: var(--ray-space-1); flex-wrap: wrap; }
|
|
22
|
+
/* !important: the joined rules of ray-button-group are more specific. */
|
|
23
|
+
:host([spaced]) ::slotted(*) {
|
|
24
|
+
margin: 0 !important;
|
|
25
|
+
--ray-group-radius-ss: initial !important;
|
|
26
|
+
--ray-group-radius-se: initial !important;
|
|
27
|
+
--ray-group-radius-es: initial !important;
|
|
28
|
+
--ray-group-radius-ee: initial !important;
|
|
29
|
+
}
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
// Group properties handed down to toggles that do not set their own.
|
|
33
|
+
const INHERITED = ["variant", "color", "size", "square", "radius"];
|
|
34
|
+
|
|
35
|
+
export class RayToggleGroup extends RayButtonGroup {
|
|
36
|
+
static formAssociated = true;
|
|
37
|
+
static properties = {
|
|
38
|
+
...RayButtonGroup.properties,
|
|
39
|
+
type: { type: String, reflect: true, default: "single" },
|
|
40
|
+
value: { type: String },
|
|
41
|
+
name: { type: String, reflect: true },
|
|
42
|
+
required: { type: Boolean, reflect: true },
|
|
43
|
+
disabled: { type: Boolean, reflect: true },
|
|
44
|
+
spaced: { type: Boolean, reflect: true },
|
|
45
|
+
variant: { type: String, reflect: true },
|
|
46
|
+
color: { type: String, reflect: true },
|
|
47
|
+
size: { type: String, reflect: true },
|
|
48
|
+
radius: { type: String, reflect: true },
|
|
49
|
+
square: { type: Boolean, reflect: true },
|
|
50
|
+
};
|
|
51
|
+
static styles = [...RayButtonGroup.styles, styles];
|
|
52
|
+
|
|
53
|
+
/** What the group set on each toggle (so the toggle's own attributes win). */
|
|
54
|
+
#handed = new WeakMap();
|
|
55
|
+
#initial = null;
|
|
56
|
+
#fieldsetDisabled = false;
|
|
57
|
+
|
|
58
|
+
constructor() {
|
|
59
|
+
super();
|
|
60
|
+
this.internals = this.attachInternals();
|
|
61
|
+
this.$("slot").addEventListener("slotchange", () => this.requestUpdate());
|
|
62
|
+
// A toggle flipped itself: apply the group's rule, then announce the
|
|
63
|
+
// group's value. The toggles' own change events stop here.
|
|
64
|
+
this.addEventListener("change", (e) => {
|
|
65
|
+
const t = e.target;
|
|
66
|
+
if (t === this) return;
|
|
67
|
+
e.stopPropagation();
|
|
68
|
+
if (t.localName === "ray-toggle" && t.closest("ray-toggle-group") === this) this.#flipped(t);
|
|
69
|
+
});
|
|
70
|
+
this.addEventListener("keydown", (e) => this.#key(e));
|
|
71
|
+
this.addEventListener("focusin", (e) => {
|
|
72
|
+
const t = e.composedPath().find((n) => n.localName === "ray-toggle");
|
|
73
|
+
if (t && this.toggles.includes(t)) this.#rove(t);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
connectedCallback() {
|
|
78
|
+
this.#initial ??= this.getAttribute("value");
|
|
79
|
+
super.connectedCallback();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
formDisabledCallback(disabled) {
|
|
83
|
+
this.#fieldsetDisabled = disabled;
|
|
84
|
+
this.requestUpdate();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
formResetCallback() {
|
|
88
|
+
this.value = this.#initial ?? undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The toggles of this group (not of a nested one). */
|
|
92
|
+
get toggles() {
|
|
93
|
+
return [...this.querySelectorAll("ray-toggle")].filter((t) => t.closest("ray-toggle-group") === this);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get #multiple() {
|
|
97
|
+
return this.type === "multiple";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The selected values as a list (single: zero or one). */
|
|
101
|
+
get values() {
|
|
102
|
+
const v = this.value;
|
|
103
|
+
if (Array.isArray(v)) return [...v];
|
|
104
|
+
if (v === null || v === undefined || v === "") return [];
|
|
105
|
+
return this.#multiple ? String(v).split(",").map((s) => s.trim()).filter(Boolean) : [String(v)];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
set values(list) {
|
|
109
|
+
this.value = this.#multiple ? [...(list ?? [])] : (list?.[0] ?? undefined);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#key(e) {
|
|
113
|
+
const vertical = this.orientation === "vertical";
|
|
114
|
+
const next = vertical ? "ArrowDown" : "ArrowRight";
|
|
115
|
+
const prev = vertical ? "ArrowUp" : "ArrowLeft";
|
|
116
|
+
if (![next, prev, "Home", "End"].includes(e.key)) return;
|
|
117
|
+
const items = this.toggles.filter((t) => !t.disabled);
|
|
118
|
+
if (items.length === 0) return;
|
|
119
|
+
const current = items.findIndex((t) => t.matches(":focus-within"));
|
|
120
|
+
let i = current;
|
|
121
|
+
if (e.key === "Home") i = 0;
|
|
122
|
+
else if (e.key === "End") i = items.length - 1;
|
|
123
|
+
else if (e.key === next) i = (current + 1) % items.length;
|
|
124
|
+
else i = (current - 1 + items.length) % items.length;
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
this.#rove(items[i]);
|
|
127
|
+
items[i].focus();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// One tab stop: the given toggle gets tabindex 0, the others -1 (a host
|
|
131
|
+
// with a negative tabindex takes its shadow button out of the tab order).
|
|
132
|
+
#rove(target) {
|
|
133
|
+
for (const t of this.toggles) t.tabIndex = t === target ? 0 : -1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#keyOf(t) {
|
|
137
|
+
return t.value ?? t.textContent.trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#flipped(t) {
|
|
141
|
+
const key = this.#keyOf(t);
|
|
142
|
+
const before = this.values;
|
|
143
|
+
let next;
|
|
144
|
+
if (this.#multiple) {
|
|
145
|
+
next = t.pressed ? [...before.filter((v) => v !== key), key] : before.filter((v) => v !== key);
|
|
146
|
+
// Keep the order of the toggles, not of the clicks.
|
|
147
|
+
const order = this.toggles.map((x) => this.#keyOf(x));
|
|
148
|
+
next.sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
|
149
|
+
} else if (t.pressed) {
|
|
150
|
+
next = [key];
|
|
151
|
+
} else {
|
|
152
|
+
// Single and required: the selected toggle cannot be turned off.
|
|
153
|
+
next = this.required ? [key] : [];
|
|
154
|
+
}
|
|
155
|
+
this.values = next;
|
|
156
|
+
this.#sync();
|
|
157
|
+
if (next.join("\n") !== before.join("\n")) {
|
|
158
|
+
this.dispatchEvent(new Event("change", { bubbles: true }));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Pressed states, handed-down properties and tab stops from the value.
|
|
163
|
+
#sync() {
|
|
164
|
+
const selected = this.values;
|
|
165
|
+
const toggles = this.toggles;
|
|
166
|
+
const disabled = Boolean(this.disabled) || this.#fieldsetDisabled;
|
|
167
|
+
for (const t of toggles) {
|
|
168
|
+
t.pressed = selected.includes(this.#keyOf(t));
|
|
169
|
+
let handed = this.#handed.get(t);
|
|
170
|
+
if (!handed) this.#handed.set(t, (handed = new Set()));
|
|
171
|
+
for (const prop of [...INHERITED, "disabled"]) {
|
|
172
|
+
const groupValue = prop === "disabled" ? (disabled || undefined) : this[prop];
|
|
173
|
+
const attr = prop;
|
|
174
|
+
if (t.hasAttribute(attr) && !handed.has(prop)) continue;
|
|
175
|
+
if (groupValue === undefined || groupValue === null || groupValue === false) {
|
|
176
|
+
if (handed.has(prop)) {
|
|
177
|
+
t[prop] = prop === "square" || prop === "disabled" ? false : undefined;
|
|
178
|
+
handed.delete(prop);
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
t[prop] = groupValue;
|
|
182
|
+
handed.add(prop);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const stop = toggles.find((t) => t.pressed && !t.disabled) ?? toggles.find((t) => !t.disabled);
|
|
187
|
+
if (!toggles.some((t) => t.matches(":focus-within"))) this.#rove(stop);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
update(changed) {
|
|
191
|
+
super.update(changed);
|
|
192
|
+
// A single group never keeps more than one value.
|
|
193
|
+
if (!this.#multiple && this.values.length > 1) this.commitProperty("value", this.values[0]);
|
|
194
|
+
this.#sync();
|
|
195
|
+
const values = this.values;
|
|
196
|
+
if (this.name && values.length) {
|
|
197
|
+
if (this.#multiple) {
|
|
198
|
+
const data = new FormData();
|
|
199
|
+
for (const v of values) data.append(this.name, v);
|
|
200
|
+
this.internals.setFormValue(data);
|
|
201
|
+
} else {
|
|
202
|
+
this.internals.setFormValue(values[0]);
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
this.internals.setFormValue(null);
|
|
206
|
+
}
|
|
207
|
+
const first = this.toggles[0];
|
|
208
|
+
if (this.required && values.length === 0) {
|
|
209
|
+
this.internals.setValidity({ valueMissing: true }, "Select an option.", first?._el ?? undefined);
|
|
210
|
+
} else {
|
|
211
|
+
this.internals.setValidity({});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
define("ray-toggle-group", RayToggleGroup);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// <ray-tooltip label="…">: a short hint shown on hover and focus of its
|
|
2
|
+
// child. The label also becomes the child's accessible description.
|
|
3
|
+
|
|
4
|
+
import { css, define, uid } from "../../core/core.js";
|
|
5
|
+
import { Floating, floatingStyles } from "../../core/floating.js";
|
|
6
|
+
import { applyColor } from "../../core/styles.js";
|
|
7
|
+
|
|
8
|
+
const styles = css`
|
|
9
|
+
:host { display: inline-flex; }
|
|
10
|
+
.panel {
|
|
11
|
+
max-width: 20rem;
|
|
12
|
+
padding: var(--ray-space-1) var(--ray-space-2);
|
|
13
|
+
font-size: var(--ray-font-size-xs);
|
|
14
|
+
line-height: var(--ray-line-height-sm);
|
|
15
|
+
color: var(--ray-background);
|
|
16
|
+
background: var(--ray-foreground);
|
|
17
|
+
border: 0;
|
|
18
|
+
border-radius: var(--ray-radius-sm);
|
|
19
|
+
box-shadow: var(--ray-shadow-sm);
|
|
20
|
+
pointer-events: none;
|
|
21
|
+
white-space: normal;
|
|
22
|
+
overflow: visible;
|
|
23
|
+
}
|
|
24
|
+
.panel[data-colored] { color: var(--_contrast); background: var(--_filled); }
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
export class RayTooltip extends Floating {
|
|
28
|
+
static properties = {
|
|
29
|
+
...Floating.properties,
|
|
30
|
+
label: { type: String },
|
|
31
|
+
placement: { type: String, reflect: true, default: "top" },
|
|
32
|
+
offset: { type: Number, default: 6 },
|
|
33
|
+
openDelay: { type: Number, default: 300 },
|
|
34
|
+
closeDelay: { type: Number, default: 0 },
|
|
35
|
+
color: { type: String, reflect: true },
|
|
36
|
+
disabled: { type: Boolean, reflect: true },
|
|
37
|
+
};
|
|
38
|
+
static styles = [floatingStyles, styles];
|
|
39
|
+
static popoverMode = "manual";
|
|
40
|
+
static lightDismiss = false;
|
|
41
|
+
static haspopup = null;
|
|
42
|
+
static triggerSlot = "slot:not([name])";
|
|
43
|
+
static template = `<slot></slot><div class="panel" part="tooltip" role="tooltip"></div>`;
|
|
44
|
+
|
|
45
|
+
constructor() {
|
|
46
|
+
super();
|
|
47
|
+
this._open = () => this._schedule(true);
|
|
48
|
+
this._close = () => this._schedule(false);
|
|
49
|
+
this._esc = (e) => { if (e.key === "Escape") this._schedule(false, 0); };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
wireTrigger(t) {
|
|
53
|
+
// The description lives in the light DOM (an unassigned, unrendered child)
|
|
54
|
+
// so the trigger can reference it by id.
|
|
55
|
+
if (!this._desc) {
|
|
56
|
+
this._desc = document.createElement("span");
|
|
57
|
+
this._desc.slot = "description";
|
|
58
|
+
this._desc.id = uid("tooltip");
|
|
59
|
+
this._desc.hidden = true;
|
|
60
|
+
this.append(this._desc);
|
|
61
|
+
}
|
|
62
|
+
const ids = new Set((t.getAttribute("aria-describedby") ?? "").split(/\s+/).filter(Boolean));
|
|
63
|
+
ids.add(this._desc.id);
|
|
64
|
+
t.setAttribute("aria-describedby", [...ids].join(" "));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
connectedCallback() {
|
|
68
|
+
super.connectedCallback();
|
|
69
|
+
this.addEventListener("pointerenter", this._open);
|
|
70
|
+
this.addEventListener("pointerleave", this._close);
|
|
71
|
+
this.addEventListener("focusin", this._open);
|
|
72
|
+
this.addEventListener("focusout", this._close);
|
|
73
|
+
this.addEventListener("pointerdown", this._close);
|
|
74
|
+
document.addEventListener("keydown", this._esc);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
disconnectedCallback() {
|
|
78
|
+
super.disconnectedCallback();
|
|
79
|
+
document.removeEventListener("keydown", this._esc);
|
|
80
|
+
clearTimeout(this._timer);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
update(changed) {
|
|
84
|
+
this.panel.textContent = this.label ?? "";
|
|
85
|
+
if (this._desc) this._desc.textContent = this.label ?? "";
|
|
86
|
+
this.panel.toggleAttribute("data-colored", Boolean(this.color));
|
|
87
|
+
if (this.color) applyColor(this.panel, this.color);
|
|
88
|
+
if ((this.disabled || !this.label) && this.open) this.commitProperty("open", false);
|
|
89
|
+
super.update(changed);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_schedule(open, delay = open ? this.openDelay : this.closeDelay) {
|
|
93
|
+
clearTimeout(this._timer);
|
|
94
|
+
if (open && (this.disabled || !this.label)) return;
|
|
95
|
+
const apply = () => { this.open = open; };
|
|
96
|
+
if (delay > 0) this._timer = setTimeout(apply, delay);
|
|
97
|
+
else apply();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
define("ray-tooltip", RayTooltip);
|
package/core/charts.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Chart theming: ray-elements draws no charts; this hands the chart tokens
|
|
2
|
+
// (--ray-chart-*, tokens.toml) to a chart library.
|
|
3
|
+
//
|
|
4
|
+
// SVG/HTML charts can use the CSS variables directly (fill: var(--ray-chart-1))
|
|
5
|
+
// and follow the theme by themselves. Canvas libraries (Chart.js, uPlot,
|
|
6
|
+
// ECharts…) need concrete colors: chartTheme() resolves them for the
|
|
7
|
+
// current theme of an element, and onChartThemeChange() tells when to redraw.
|
|
8
|
+
|
|
9
|
+
import { onThemeChange, resolvedTheme } from "./core.js";
|
|
10
|
+
|
|
11
|
+
/** Number of series colors (--ray-chart-1 … --ray-chart-8). */
|
|
12
|
+
export const CHART_SERIES = 8;
|
|
13
|
+
|
|
14
|
+
let ctx = null;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A computed CSS color as "#rrggbb" (opaque) or "rgba(r, g, b, a)" — what every
|
|
18
|
+
* canvas accepts. Computed values come as rgb()/rgba() or, for color-mix() and
|
|
19
|
+
* friends, as color(srgb r g b / a), which WebKit's canvas does not take:
|
|
20
|
+
* parsed here rather than trusted to the canvas.
|
|
21
|
+
*/
|
|
22
|
+
function normalize(color) {
|
|
23
|
+
const c = String(color).trim();
|
|
24
|
+
let rgba = null;
|
|
25
|
+
const srgb = c.match(/^color\(srgb\s+([^)]+)\)$/i);
|
|
26
|
+
const fn = c.match(/^rgba?\(([^)]+)\)$/i);
|
|
27
|
+
if (srgb) {
|
|
28
|
+
const [chan, alpha] = srgb[1].split("/");
|
|
29
|
+
const [r, g, b] = chan.trim().split(/\s+/).map((x) => Number(x) * 255);
|
|
30
|
+
rgba = [r, g, b, alpha === undefined ? 1 : Number(alpha)];
|
|
31
|
+
} else if (fn) {
|
|
32
|
+
const parts = fn[1].replace("/", " ").split(/[\s,]+/).filter(Boolean).map(Number);
|
|
33
|
+
rgba = [parts[0], parts[1], parts[2], parts.length > 3 ? parts[3] : 1];
|
|
34
|
+
}
|
|
35
|
+
if (!rgba || rgba.some((x) => Number.isNaN(x))) {
|
|
36
|
+
// Anything else (a named color, hex): let the canvas serialize it.
|
|
37
|
+
ctx ??= document.createElement("canvas").getContext("2d");
|
|
38
|
+
ctx.fillStyle = "#000000";
|
|
39
|
+
ctx.fillStyle = c;
|
|
40
|
+
return ctx.fillStyle;
|
|
41
|
+
}
|
|
42
|
+
const [r, g, b, a] = rgba.map((x, i) => (i < 3 ? Math.round(Math.min(255, Math.max(0, x))) : x));
|
|
43
|
+
if (a >= 1) return `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`;
|
|
44
|
+
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A color with an alpha, for area fills and highlights: withAlpha("#1c7ed6", 0.2).
|
|
49
|
+
* @returns {string} rgba(...)
|
|
50
|
+
*/
|
|
51
|
+
export function withAlpha(color, alpha) {
|
|
52
|
+
const c = normalize(color);
|
|
53
|
+
const hex = c.startsWith("#") ? c : null;
|
|
54
|
+
let r, g, b;
|
|
55
|
+
if (hex) {
|
|
56
|
+
const n = parseInt(hex.slice(1), 16);
|
|
57
|
+
[r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
58
|
+
} else {
|
|
59
|
+
[r, g, b] = c.match(/[\d.]+/g).map(Number);
|
|
60
|
+
}
|
|
61
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The chart tokens resolved for the theme of `el` (default: the document):
|
|
66
|
+
* its color scheme, data-primary and any token overrides apply.
|
|
67
|
+
* @param {Element} [el]
|
|
68
|
+
*/
|
|
69
|
+
export function chartTheme(el = document.documentElement) {
|
|
70
|
+
const host = el === document.documentElement ? document.body ?? el : el;
|
|
71
|
+
const probe = document.createElement("span");
|
|
72
|
+
probe.style.cssText = "position:absolute;visibility:hidden;pointer-events:none;width:0;height:0;overflow:hidden";
|
|
73
|
+
host.append(probe);
|
|
74
|
+
const color = (name) => {
|
|
75
|
+
probe.style.color = `var(--ray-${name})`;
|
|
76
|
+
return normalize(getComputedStyle(probe).color);
|
|
77
|
+
};
|
|
78
|
+
try {
|
|
79
|
+
const series = [];
|
|
80
|
+
for (let i = 1; i <= CHART_SERIES; i++) series.push(color(`chart-${i}`));
|
|
81
|
+
probe.style.fontFamily = "var(--ray-font-sans)";
|
|
82
|
+
probe.style.fontSize = "var(--ray-font-size-xs)";
|
|
83
|
+
const cs = getComputedStyle(probe);
|
|
84
|
+
return {
|
|
85
|
+
scheme: resolvedTheme(el),
|
|
86
|
+
series,
|
|
87
|
+
background: color("background"),
|
|
88
|
+
grid: color("chart-grid"),
|
|
89
|
+
axis: color("chart-axis"),
|
|
90
|
+
text: color("chart-text"),
|
|
91
|
+
tooltip: { background: color("chart-tooltip"), text: color("chart-tooltip-text"), border: color("chart-tooltip-border") },
|
|
92
|
+
font: { family: cs.fontFamily, size: parseFloat(cs.fontSize) },
|
|
93
|
+
};
|
|
94
|
+
} finally {
|
|
95
|
+
probe.remove();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Calls `cb(chartTheme(el))` whenever the theme changes (setTheme,
|
|
101
|
+
* setPrimary, definePrimary, the system scheme). Returns a function that stops.
|
|
102
|
+
* @param {(theme: ReturnType<typeof chartTheme>) => void} cb
|
|
103
|
+
*/
|
|
104
|
+
export function onChartThemeChange(cb, el = document.documentElement) {
|
|
105
|
+
// After the change has applied (attributes and styles are set synchronously).
|
|
106
|
+
return onThemeChange(() => queueMicrotask(() => cb(chartTheme(el))));
|
|
107
|
+
}
|
package/core/color.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Color math for definePrimary(): parse any CSS color, build a ten-shade
|
|
2
|
+
// scale in OKLCH (perceptually even lightness, same hue), and measure WCAG
|
|
3
|
+
// contrast. OKLab from Björn Ottosson (https://bottosson.github.io/posts/oklab/).
|
|
4
|
+
|
|
5
|
+
/** Target lightness (OKLab L) of shades 0…9, like the open-color scales. */
|
|
6
|
+
const LIGHTNESS = [0.97, 0.93, 0.87, 0.8, 0.73, 0.67, 0.61, 0.55, 0.49, 0.43];
|
|
7
|
+
/** Chroma of each shade relative to the base color's (pale ends are less saturated). */
|
|
8
|
+
const CHROMA = [0.12, 0.25, 0.45, 0.65, 0.8, 0.92, 1, 1, 0.95, 0.88];
|
|
9
|
+
|
|
10
|
+
let ctx = null;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Any CSS color the browser understands ("#7c3aed", "rebeccapurple",
|
|
14
|
+
* "oklch(60% 0.2 280)") as sRGB channels 0…1, or null if it is not a color.
|
|
15
|
+
*/
|
|
16
|
+
export function parseColor(input) {
|
|
17
|
+
if (typeof input !== "string" || !input.trim()) return null;
|
|
18
|
+
ctx ??= document.createElement("canvas").getContext("2d");
|
|
19
|
+
// A sentinel tells an invalid color (fillStyle keeps the previous value) apart.
|
|
20
|
+
for (const sentinel of ["#010203", "#040506"]) {
|
|
21
|
+
ctx.fillStyle = sentinel;
|
|
22
|
+
ctx.fillStyle = input.trim();
|
|
23
|
+
const v = ctx.fillStyle;
|
|
24
|
+
if (v !== sentinel) return fromCanvas(v);
|
|
25
|
+
}
|
|
26
|
+
// Both sentinels survived: the input is one of them, or invalid.
|
|
27
|
+
const t = input.trim().toLowerCase();
|
|
28
|
+
return t === "#010203" || t === "#040506" ? fromCanvas(t) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fromCanvas(v) {
|
|
32
|
+
if (v.startsWith("#")) {
|
|
33
|
+
const n = parseInt(v.slice(1), 16);
|
|
34
|
+
return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255 };
|
|
35
|
+
}
|
|
36
|
+
const m = v.match(/rgba?\(([^)]+)\)/);
|
|
37
|
+
if (!m) return null;
|
|
38
|
+
const [r, g, b] = m[1].split(",").map((x) => Number(x.trim()) / 255);
|
|
39
|
+
return { r, g, b };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function toHex({ r, g, b }) {
|
|
43
|
+
const h = (x) => Math.round(Math.min(1, Math.max(0, x)) * 255).toString(16).padStart(2, "0");
|
|
44
|
+
return `#${h(r)}${h(g)}${h(b)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
48
|
+
const fromLinear = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
|
|
49
|
+
|
|
50
|
+
export function toOklch({ r, g, b }) {
|
|
51
|
+
const [lr, lg, lb] = [toLinear(r), toLinear(g), toLinear(b)];
|
|
52
|
+
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
|
53
|
+
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
|
54
|
+
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
|
55
|
+
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
|
|
56
|
+
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
|
|
57
|
+
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
|
|
58
|
+
return { l: L, c: Math.hypot(A, B), h: Math.atan2(B, A) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function fromOklch({ l: L, c, h }) {
|
|
62
|
+
const A = c * Math.cos(h);
|
|
63
|
+
const B = c * Math.sin(h);
|
|
64
|
+
const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
|
|
65
|
+
const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
|
|
66
|
+
const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
|
|
67
|
+
return {
|
|
68
|
+
r: fromLinear(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
|
69
|
+
g: fromLinear(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
|
70
|
+
b: fromLinear(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const inGamut = ({ r, g, b }) => [r, g, b].every((x) => x >= -0.0005 && x <= 1.0005);
|
|
75
|
+
|
|
76
|
+
/** The color with chroma reduced until it fits in sRGB. */
|
|
77
|
+
function fit(lch) {
|
|
78
|
+
let c = lch.c;
|
|
79
|
+
let rgb = fromOklch({ ...lch, c });
|
|
80
|
+
for (let i = 0; i < 60 && !inGamut(rgb); i++) {
|
|
81
|
+
c *= 0.95;
|
|
82
|
+
rgb = fromOklch({ ...lch, c });
|
|
83
|
+
}
|
|
84
|
+
return rgb;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Ten shades (0 = lightest) around `rgb`: the base color itself sits at the
|
|
89
|
+
* shade whose lightness is closest to its own; the others keep its hue.
|
|
90
|
+
* @returns {string[]} hex colors
|
|
91
|
+
*/
|
|
92
|
+
export function shades(rgb) {
|
|
93
|
+
const base = toOklch(rgb);
|
|
94
|
+
let closest = 0;
|
|
95
|
+
LIGHTNESS.forEach((l, i) => {
|
|
96
|
+
if (Math.abs(l - base.l) < Math.abs(LIGHTNESS[closest] - base.l)) closest = i;
|
|
97
|
+
});
|
|
98
|
+
return LIGHTNESS.map((l, i) => (i === closest ? toHex(rgb) : toHex(fit({ l, c: base.c * CHROMA[i], h: base.h }))));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function luminance(hex) {
|
|
102
|
+
const { r, g, b } = fromCanvas(hex);
|
|
103
|
+
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** WCAG contrast ratio of two hex colors (1…21). */
|
|
107
|
+
export function contrastRatio(a, b) {
|
|
108
|
+
const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
|
|
109
|
+
return (x + 0.05) / (y + 0.05);
|
|
110
|
+
}
|