@raylang/elements 0.0.0-stage → 0.3.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.
Files changed (95) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +12 -2
  4. package/base.css +111 -0
  5. package/components/accordion/accordion.js +197 -0
  6. package/components/alert/alert.js +112 -0
  7. package/components/app-bar/app-bar.js +168 -0
  8. package/components/app-shell/app-shell.js +198 -0
  9. package/components/aspect-ratio/aspect-ratio.js +59 -0
  10. package/components/attachment/attachment.js +333 -0
  11. package/components/avatar/avatar.js +96 -0
  12. package/components/badge/badge.js +63 -0
  13. package/components/blockquote/blockquote.js +128 -0
  14. package/components/bottom-nav/bottom-nav.js +309 -0
  15. package/components/breadcrumbs/breadcrumbs.js +175 -0
  16. package/components/button/button.js +283 -0
  17. package/components/button-group/button-group.js +72 -0
  18. package/components/card/card.js +73 -0
  19. package/components/carousel/carousel.js +435 -0
  20. package/components/center/center.js +26 -0
  21. package/components/checkbox/checkbox.js +152 -0
  22. package/components/checkbox-group/checkbox-group.js +152 -0
  23. package/components/code/code.js +574 -0
  24. package/components/color-picker/color-picker.js +775 -0
  25. package/components/combobox/combobox.js +563 -0
  26. package/components/command-palette/command-palette.js +510 -0
  27. package/components/container/container.js +41 -0
  28. package/components/date-picker/date-picker.js +594 -0
  29. package/components/dialog/dialog.js +329 -0
  30. package/components/drawer/drawer.js +139 -0
  31. package/components/empty-state/empty-state.js +66 -0
  32. package/components/file-upload/file-upload.js +589 -0
  33. package/components/grid/grid.js +96 -0
  34. package/components/group/group.js +40 -0
  35. package/components/icon/icon.js +58 -0
  36. package/components/icon-button/icon-button.js +30 -0
  37. package/components/indicator/indicator.js +112 -0
  38. package/components/input/input.js +85 -0
  39. package/components/kbd/kbd.js +60 -0
  40. package/components/menu/menu.js +296 -0
  41. package/components/nav-link/nav-link.js +159 -0
  42. package/components/notification/notification.js +591 -0
  43. package/components/number-input/number-input.js +241 -0
  44. package/components/pagination/pagination.js +194 -0
  45. package/components/password-input/password-input.js +85 -0
  46. package/components/path-input/path-input.js +137 -0
  47. package/components/popover/popover.js +85 -0
  48. package/components/progress/progress.js +109 -0
  49. package/components/radio/radio.js +253 -0
  50. package/components/scroll-area/scroll-area.js +158 -0
  51. package/components/search/search.js +157 -0
  52. package/components/segmented-control/segmented-control.js +190 -0
  53. package/components/select/select.js +506 -0
  54. package/components/separator/separator.js +58 -0
  55. package/components/sidebar/sidebar.js +252 -0
  56. package/components/skeleton/skeleton.js +90 -0
  57. package/components/slider/slider.js +155 -0
  58. package/components/spinner/spinner.js +39 -0
  59. package/components/split/split.js +221 -0
  60. package/components/stack/stack.js +46 -0
  61. package/components/stat/stat.js +79 -0
  62. package/components/statusbar/statusbar.js +110 -0
  63. package/components/stepper/stepper.js +239 -0
  64. package/components/switch/switch.js +45 -0
  65. package/components/table/table.js +328 -0
  66. package/components/tabs/tabs.js +224 -0
  67. package/components/textarea/textarea.js +105 -0
  68. package/components/theme-toggle/theme-toggle.js +78 -0
  69. package/components/timeline/timeline.js +150 -0
  70. package/components/toggle/toggle.js +130 -0
  71. package/components/toggle-group/toggle-group.js +216 -0
  72. package/components/tooltip/tooltip.js +101 -0
  73. package/core/charts.js +107 -0
  74. package/core/color.js +110 -0
  75. package/core/core.js +556 -0
  76. package/core/dismiss.js +69 -0
  77. package/core/floating.js +195 -0
  78. package/core/focus.js +70 -0
  79. package/core/form.js +235 -0
  80. package/core/host.js +200 -0
  81. package/core/hotkey.js +40 -0
  82. package/core/icons.js +104 -0
  83. package/core/motion.js +73 -0
  84. package/core/position.js +91 -0
  85. package/core/raylang.js +167 -0
  86. package/core/scroll.js +58 -0
  87. package/core/styles.js +211 -0
  88. package/core.js +16 -0
  89. package/custom-elements.json +18545 -0
  90. package/index.js +74 -0
  91. package/package.json +312 -4
  92. package/ray-elements.d.ts +2570 -0
  93. package/tokens.css +796 -0
  94. package/tokens.json +2083 -0
  95. package/vscode.html-custom-data.json +5499 -0
package/core/host.js ADDED
@@ -0,0 +1,200 @@
1
+ // The native host: what the program around the page can do that a browser
2
+ // cannot (system file dialogs, native confirmations). Components ask for it
3
+ // and keep their web behavior when there is none, so they stay agnostic of
4
+ // the host: they never import a bridge, a bridge installs a host here.
5
+ // core/raylang.js installs the one of std/ui windows.
6
+ //
7
+ // A host is an object with `name`, `can(op)` and the operations it offers:
8
+ //
9
+ // pickFiles({ title, multiple, directory, filters: [{ name, extensions }] })
10
+ // -> Promise<HostFile[]> ([] when the user cancels)
11
+ // pickPath({ mode, title, directory, suggested, filters })
12
+ // -> Promise<string|null> (mode: "open", "folder" or "save"; null = cancelled)
13
+ // confirm({ title, message, confirmLabel, cancelLabel, style })
14
+ // -> Promise<boolean> (style: "", "info", "warning" or "error")
15
+ // menuItems()
16
+ // -> Promise<[{ menu, tag, title, shortcut, enabled, checked }]> (the app's menu bar)
17
+ // runMenu(tag)
18
+ // -> Promise<true> (the program gets the item as if chosen in the menu bar)
19
+ // popupMenu([{ title, enabled, checked, separator }])
20
+ // -> Promise<number|null> (a native context menu at the pointer: the index
21
+ // chosen, null when dismissed or replaced)
22
+ // setEdited(edited) (the window's "unsaved changes" mark)
23
+ // -> Promise<true>
24
+ // titlebar ({ height, inset } in px when the page runs under a
25
+ // transparent title bar, else null: reserve `height`
26
+ // at the top, `inset` at the start for the window
27
+ // buttons; also --ray-titlebar-height and
28
+ // --ray-titlebar-inset on <html>)
29
+
30
+ let current = null;
31
+ const listeners = new Set();
32
+
33
+ /** Installs the host (null removes it) and tells the listeners. */
34
+ export function setHost(host) {
35
+ current = host ?? null;
36
+ for (const cb of [...listeners]) cb(current);
37
+ }
38
+
39
+ /** The installed host, or null. */
40
+ export function getHost() {
41
+ return current;
42
+ }
43
+
44
+ /** Whether the installed host offers `op` ("pick_files", "confirm"…). */
45
+ export function hostCan(op) {
46
+ return Boolean(current && typeof current.can === "function" && current.can(op));
47
+ }
48
+
49
+ /** Calls `cb(host)` whenever the host changes; returns the unsubscribe function. */
50
+ export function onHostChange(cb) {
51
+ listeners.add(cb);
52
+ return () => listeners.delete(cb);
53
+ }
54
+
55
+ /** The title bar the page runs under ({ height, inset } px), or null. */
56
+ export function titlebar() {
57
+ const t = current?.titlebar;
58
+ return t && t.height > 0 ? t : null;
59
+ }
60
+
61
+ // Interactive elements inside a drag handle stay clickable.
62
+ const INTERACTIVE = "button, a[href], input, select, textarea, label, summary, [contenteditable], [role=button], [role=tab], [role=link], [role=menuitem], [tabindex]:not([tabindex='-1']), [data-ray-no-drag]";
63
+
64
+ /**
65
+ * Makes `el` drag the window when the page runs under a host's title bar
66
+ * (titlebar() is not null): it gets data-ray-drag, the attribute the std/ui
67
+ * shell reads. The shell looks at the event's target, which a shadow root
68
+ * retargets to its host, so for a pointer down on anything interactive
69
+ * inside `el` (a button in its shadow root too) the attribute steps aside
70
+ * until the pointer is up. Returns the function that undoes it.
71
+ */
72
+ export function dragRegion(el) {
73
+ const guard = (e) => {
74
+ if (!el.hasAttribute("data-ray-drag")) return;
75
+ const path = e.composedPath();
76
+ const end = path.indexOf(el);
77
+ const hit = path.slice(0, end < 0 ? 0 : end).some((n) => n instanceof Element && n.matches(INTERACTIVE));
78
+ if (!hit) return;
79
+ el.removeAttribute("data-ray-drag");
80
+ const back = () => {
81
+ removeEventListener("pointerup", back, true);
82
+ removeEventListener("pointercancel", back, true);
83
+ if (titlebar()) el.setAttribute("data-ray-drag", "");
84
+ };
85
+ addEventListener("pointerup", back, true);
86
+ addEventListener("pointercancel", back, true);
87
+ };
88
+ const sync = () => el.toggleAttribute("data-ray-drag", Boolean(titlebar()));
89
+ el.addEventListener("pointerdown", guard, true);
90
+ const stop = onHostChange(sync);
91
+ sync();
92
+ return () => {
93
+ stop();
94
+ el.removeEventListener("pointerdown", guard, true);
95
+ el.removeAttribute("data-ray-drag");
96
+ };
97
+ }
98
+
99
+ const formKey = (data) => JSON.stringify([...data].map(([k, v]) => [k, typeof v === "string" ? v : `${v.name}:${v.size}:${v.lastModified}`]));
100
+
101
+ /**
102
+ * Follows whether `form` has unsaved changes: its FormData differs from the
103
+ * one it had when tracking started (or since clean()). With a host that
104
+ * offers "set_edited", the window shows it (the macOS close button's dot).
105
+ * `onChange(dirty)` is called when it flips. Submit and reset make it clean.
106
+ */
107
+ export function trackEdits(form, { onChange } = {}) {
108
+ let saved = formKey(new FormData(form));
109
+ let dirty = false;
110
+ const set = (next) => {
111
+ if (next === dirty) return;
112
+ dirty = next;
113
+ if (hostCan("set_edited")) current.setEdited(dirty).catch(() => {});
114
+ onChange?.(dirty);
115
+ };
116
+ const compare = () => set(formKey(new FormData(form)) !== saved);
117
+ // After the controls' own updates (form-associated elements set their
118
+ // value in a microtask when it is assigned by code).
119
+ const check = () => queueMicrotask(() => queueMicrotask(compare));
120
+ const clean = () => {
121
+ saved = formKey(new FormData(form));
122
+ set(false);
123
+ };
124
+ const onReset = () => setTimeout(clean, 0);
125
+ form.addEventListener("input", check);
126
+ form.addEventListener("change", check);
127
+ form.addEventListener("submit", clean);
128
+ form.addEventListener("reset", onReset);
129
+ return {
130
+ get dirty() { return dirty; },
131
+ /** Takes the current values as saved (after saving them some other way). */
132
+ clean,
133
+ /** Compares now (the events already do it). */
134
+ check: compare,
135
+ stop() {
136
+ form.removeEventListener("input", check);
137
+ form.removeEventListener("change", check);
138
+ form.removeEventListener("submit", clean);
139
+ form.removeEventListener("reset", onReset);
140
+ set(false);
141
+ },
142
+ };
143
+ }
144
+
145
+ const TYPES = {
146
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
147
+ avif: "image/avif", svg: "image/svg+xml", bmp: "image/bmp", heic: "image/heic", ico: "image/x-icon",
148
+ mp4: "video/mp4", mov: "video/quicktime", webm: "video/webm", mkv: "video/x-matroska",
149
+ mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4", flac: "audio/flac",
150
+ txt: "text/plain", md: "text/markdown", csv: "text/csv", html: "text/html", css: "text/css",
151
+ js: "text/javascript", xml: "text/xml", json: "application/json", pdf: "application/pdf",
152
+ zip: "application/zip", gz: "application/gzip", tar: "application/x-tar",
153
+ };
154
+
155
+ /** The MIME type of a file name, from its extension ("" when unknown). */
156
+ export function typeOfName(name) {
157
+ const dot = String(name).lastIndexOf(".");
158
+ return dot < 0 ? "" : TYPES[String(name).slice(dot + 1).toLowerCase()] ?? "";
159
+ }
160
+
161
+ /** The extensions of a MIME type or pattern ("image/*", "application/pdf"). */
162
+ export function extensionsOfType(type) {
163
+ const t = String(type).toLowerCase();
164
+ const all = Object.keys(TYPES);
165
+ if (t.endsWith("/*")) return all.filter((x) => TYPES[x].startsWith(t.slice(0, -1)));
166
+ return all.filter((x) => TYPES[x] === t);
167
+ }
168
+
169
+ /**
170
+ * The file dialog filters of an `accept` list (".png,image/*", as in
171
+ * <input type=file>): one filter with every extension, called `name` (by
172
+ * default the extensions). [] (any file) when a type has no known
173
+ * extension, so the dialog never hides a file the list accepts.
174
+ */
175
+ export function acceptFilters(accept, name = "") {
176
+ const exts = [];
177
+ const list = String(accept ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
178
+ for (const a of list) {
179
+ const more = a.startsWith(".") ? [a.slice(1)] : extensionsOfType(a);
180
+ if (more.length === 0) return [];
181
+ for (const x of more) if (!exts.includes(x)) exts.push(x);
182
+ }
183
+ if (exts.length === 0) return [];
184
+ return [{ name: name || exts.map((x) => x.toUpperCase()).join(", "), extensions: exts }];
185
+ }
186
+
187
+ /**
188
+ * A file the host chose: a path on the user's disk instead of the bytes of
189
+ * a File. It has the File fields components read (name, size, type,
190
+ * lastModified), so it lists like one; the program reads the file.
191
+ */
192
+ export class HostFile {
193
+ constructor({ path, name, size = 0, type, lastModified = 0 }) {
194
+ this.path = String(path);
195
+ this.name = name ?? this.path.split(/[\\/]/).pop();
196
+ this.size = size >= 0 ? size : 0;
197
+ this.type = type ?? typeOfName(this.name);
198
+ this.lastModified = lastModified;
199
+ }
200
+ }
package/core/hotkey.js ADDED
@@ -0,0 +1,40 @@
1
+ // Keyboard shortcuts written as "mod+k", "shift+alt+p", "/": `mod` is ⌘ on
2
+ // macOS and Ctrl elsewhere. Shared by the command palette and ray-search.
3
+
4
+ export const IS_MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
5
+
6
+ function parse(spec) {
7
+ const parts = String(spec ?? "").toLowerCase().trim().split("+").map((s) => s.trim());
8
+ const key = parts.pop() ?? "";
9
+ const want = { meta: false, ctrl: false, alt: false, shift: false };
10
+ for (const p of parts) {
11
+ if (p === "mod") want[IS_MAC ? "meta" : "ctrl"] = true;
12
+ else if (p === "cmd" || p === "meta") want.meta = true;
13
+ else if (p === "ctrl" || p === "control") want.ctrl = true;
14
+ else if (p === "alt" || p === "option") want.alt = true;
15
+ else if (p === "shift") want.shift = true;
16
+ }
17
+ return { key, want };
18
+ }
19
+
20
+ /** Whether a keydown event is the shortcut `spec` (repeats never match). */
21
+ export function matchesHotkey(e, spec) {
22
+ const { key, want } = parse(spec);
23
+ if (!key || e.repeat) return false;
24
+ return e.key.toLowerCase() === key && e.metaKey === want.meta && e.ctrlKey === want.ctrl && e.altKey === want.alt && e.shiftKey === want.shift;
25
+ }
26
+
27
+ /** The shortcut as shown to the user: "⌘K" on macOS, "Ctrl K" elsewhere. */
28
+ export function formatHotkey(spec) {
29
+ const { key, want } = parse(spec);
30
+ if (!key) return "";
31
+ const k = key.length === 1 ? key.toUpperCase() : key[0].toUpperCase() + key.slice(1);
32
+ if (IS_MAC) return `${want.ctrl ? "⌃" : ""}${want.alt ? "⌥" : ""}${want.shift ? "⇧" : ""}${want.meta ? "⌘" : ""}${k}`;
33
+ return [want.ctrl && "Ctrl", want.alt && "Alt", want.shift && "Shift", want.meta && "Meta", k].filter(Boolean).join(" ");
34
+ }
35
+
36
+ /** True when the event comes from a place where typing is expected. */
37
+ export function isTyping(e) {
38
+ const t = e.composedPath?.()[0] ?? e.target;
39
+ return Boolean(t?.isContentEditable || (t?.localName === "input" && !["checkbox", "radio", "button"].includes(t.type)) || t?.localName === "textarea" || t?.localName === "select");
40
+ }
package/core/icons.js ADDED
@@ -0,0 +1,104 @@
1
+ // Icon registry: every icon a ray-elements component draws comes from here, so an
2
+ // app can switch the whole set (Lucide, Tabler, its own…) in one call:
3
+ //
4
+ // import { setIcons } from "@raylang/elements";
5
+ // setIcons({ close: '<svg viewBox="0 0 24 24">…</svg>', "chevron-down": … });
6
+ //
7
+ // setIcons() also adds new names, for <ray-icon name="…">. Icons are SVG
8
+ // markup (or an SVGElement) drawn with currentColor; components size them.
9
+ // The markup is inserted as HTML: register icons from your own code only,
10
+ // never from user input.
11
+
12
+ const svg = (body) =>
13
+ `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
14
+
15
+ /** The default set (24×24, 2px stroke). */
16
+ const DEFAULTS = {
17
+ "close": svg(`<path d="M6 6l12 12M18 6 6 18"/>`),
18
+ "check": svg(`<path d="M5 12.5l4.5 4.5L19 7.5"/>`),
19
+ "minus": svg(`<path d="M6 12h12"/>`),
20
+ "plus": svg(`<path d="M12 5v14M5 12h14"/>`),
21
+ "chevron-down": svg(`<path d="m6 9 6 6 6-6"/>`),
22
+ "chevron-up": svg(`<path d="m6 15 6-6 6 6"/>`),
23
+ "chevron-right": svg(`<path d="m9 6 6 6-6 6"/>`),
24
+ "chevron-left": svg(`<path d="m15 6-6 6 6 6"/>`),
25
+ "chevrons-left": svg(`<path d="m11 6-6 6 6 6M18 6l-6 6 6 6"/>`),
26
+ "chevrons-right": svg(`<path d="m13 6 6 6-6 6M6 6l6 6-6 6"/>`),
27
+ "search": svg(`<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>`),
28
+ "arrow-up-right": svg(`<path d="M7 17 17 7M8 7h9v9"/>`),
29
+ "arrow-down-right": svg(`<path d="M7 7l10 10M17 8v9H8"/>`),
30
+ "sun": svg(`<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>`),
31
+ "moon": svg(`<path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z"/>`),
32
+ "monitor": svg(`<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>`),
33
+ "user": svg(`<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>`),
34
+ "info": svg(`<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>`),
35
+ "circle-check": svg(`<circle cx="12" cy="12" r="9"/><path d="m8.5 12.5 2.5 2.5 4.5-5"/>`),
36
+ "alert-triangle": svg(`<path d="M10.3 4.2 2.6 17.5A2 2 0 0 0 4.3 20.5h15.4a2 2 0 0 0 1.7-3L13.7 4.2a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/>`),
37
+ "circle-x": svg(`<circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/>`),
38
+ "menu": svg(`<path d="M4 6h16M4 12h16M4 18h16"/>`),
39
+ "more-horizontal": svg(`<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>`),
40
+ // Common in command palettes, menus and nav links.
41
+ "file": svg(`<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/>`),
42
+ "folder": svg(`<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>`),
43
+ "pause": svg(`<rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/>`),
44
+ "play": svg(`<path d="M7 4.5v15a1 1 0 0 0 1.5.86l12-7.5a1 1 0 0 0 0-1.72l-12-7.5A1 1 0 0 0 7 4.5z"/>`),
45
+ "terminal": svg(`<path d="m5 7 5 5-5 5M12 17h7"/>`),
46
+ "calendar": svg(`<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>`),
47
+ "image": svg(`<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="m21 17-5-5-9 8"/>`),
48
+ "download": svg(`<path d="M12 4v11M7 10l5 5 5-5M5 20h14"/>`),
49
+ eye: svg(`<path d="M2.1 12.3a1 1 0 0 1 0-.6 10.8 10.8 0 0 1 19.8 0 1 1 0 0 1 0 .6 10.8 10.8 0 0 1-19.8 0"/><circle cx="12" cy="12" r="3"/>`),
50
+ "eye-off": svg(`<path d="M10.7 5.1a10.7 10.7 0 0 1 11.2 6.6 1 1 0 0 1 0 .7 10.8 10.8 0 0 1-1.4 2.5"/><path d="M14.1 14.2a3 3 0 0 1-4.2-4.2"/><path d="M17.5 17.5a10.7 10.7 0 0 1-15.4-5.1 1 1 0 0 1 0-.7 10.8 10.8 0 0 1 4.4-5.2"/><path d="m2 2 20 20"/>`),
51
+ bell: svg(`<path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/><path d="M3.3 15.3A1 1 0 0 0 4 17h16a1 1 0 0 0 .7-1.7C19.4 14 18 12.6 18 8A6 6 0 0 0 6 8c0 4.6-1.4 6-2.7 7.3"/>`),
52
+ copy: svg(`<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2"/>`),
53
+ quote: `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M10 7.5C10 6 9 5 7.5 5 4.9 5 3 7.4 3 10.8V19h7v-7H6.1c.1-2.2 1.3-3.6 2.4-3.9.9-.3 1.5-.9 1.5-1.6zM21 7.5C21 6 20 5 18.5 5 15.9 5 14 7.4 14 10.8V19h7v-7h-3.9c.1-2.2 1.3-3.6 2.4-3.9.9-.3 1.5-.9 1.5-1.6z"/></svg>`,
54
+ pipette: svg(`<path d="m2 22 1-1h3l9-9"/><path d="M3 21v-3l9-9"/><path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.4.4a2.1 2.1 0 1 1-3 3l-3.8-3.8a2.1 2.1 0 1 1 3-3l.4.4Z"/>`),
55
+ "star": svg(`<path d="M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 16.9l-5.2 2.7 1-5.8-4.3-4.1 5.9-.9z" stroke-linejoin="round"/>`),
56
+ "upload": svg(`<path d="M12 16V5M7 10l5-5 5 5M5 20h14"/>`),
57
+ "paperclip": svg(`<path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.3 3.3 0 0 1 4.7 4.7l-8.6 8.6a1.7 1.7 0 0 1-2.4-2.4L14.5 7"/>`),
58
+ "panel-left": svg(`<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/>`),
59
+ "settings": svg(`<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>`),
60
+ "home": svg(`<path d="M4 10.5 12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5.5h-5V20H5a1 1 0 0 1-1-1z"/>`),
61
+ "arrow-left": svg(`<path d="M19 12H5M11 6l-6 6 6 6"/>`),
62
+ "message-circle": svg(`<path d="M20 11.5a8 8 0 0 1-11.6 7.2L4 20l1.3-4.2A8 8 0 1 1 20 11.5z"/>`),
63
+ };
64
+
65
+ const registry = new Map(Object.entries(DEFAULTS));
66
+ const listeners = new Set();
67
+ const warned = new Set();
68
+
69
+ /**
70
+ * Replaces or adds icons. Values are SVG markup or an SVGElement.
71
+ * @param {Record<string, string | SVGElement>} icons
72
+ */
73
+ export function setIcons(icons) {
74
+ for (const [name, value] of Object.entries(icons)) {
75
+ const markup = typeof value === "string" ? value.trim() : value?.outerHTML;
76
+ if (typeof markup !== "string" || !/^<svg[\s>]/i.test(markup)) {
77
+ throw new TypeError(`setIcons: "${name}" is not SVG markup or an SVGElement`);
78
+ }
79
+ registry.set(name, markup);
80
+ }
81
+ for (const cb of listeners) cb();
82
+ }
83
+
84
+ /** The SVG markup of an icon ("" and one warning when it is not registered). */
85
+ export function getIcon(name) {
86
+ const markup = registry.get(name);
87
+ if (markup !== undefined) return markup;
88
+ if (!warned.has(name)) {
89
+ warned.add(name);
90
+ console.warn(`ray-elements: no icon named "${name}" (setIcons to add it)`);
91
+ }
92
+ return "";
93
+ }
94
+
95
+ /** The registered icon names. */
96
+ export function iconNames() {
97
+ return [...registry.keys()];
98
+ }
99
+
100
+ /** @internal Called by the core runtime to repaint components after setIcons. */
101
+ export function onIconsChange(cb) {
102
+ listeners.add(cb);
103
+ return () => listeners.delete(cb);
104
+ }
package/core/motion.js ADDED
@@ -0,0 +1,73 @@
1
+ // Motion helpers: exit animations (the CSS keyframes cover the entries) and
2
+ // height changes, with the Web Animations API so a component can wait for an
3
+ // animation to end before it really hides an element (dialog.close(),
4
+ // hidePopover(), remove()).
5
+ //
6
+ // Durations and easings come from the tokens (--ray-duration-*,
7
+ // --ray-easing-*). No motion — and an immediate result — when the user asks
8
+ // for reduced motion, when the duration token is 0, or when the page is not
9
+ // visible (hidden windows do not run animations).
10
+
11
+ const reduced = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)") : null;
12
+
13
+ /** A duration token of `el` in milliseconds ("fast" | "base" | "slow"). */
14
+ export function duration(el, name = "base") {
15
+ const v = getComputedStyle(el).getPropertyValue(`--ray-duration-${name}`).trim();
16
+ if (!v) return 0;
17
+ const n = parseFloat(v);
18
+ return Number.isNaN(n) ? 0 : v.endsWith("ms") ? n : n * 1000;
19
+ }
20
+
21
+ /** An easing token of `el` ("standard" | "in" | "out"). */
22
+ export function easing(el, name = "in") {
23
+ return getComputedStyle(el).getPropertyValue(`--ray-easing-${name}`).trim() || "ease";
24
+ }
25
+
26
+ /** Whether animations should run for `el` now. */
27
+ export function motionAllowed(el, name = "base") {
28
+ return !reduced?.matches && document.visibilityState === "visible" && duration(el, name) > 0 && typeof el.animate === "function";
29
+ }
30
+
31
+ /**
32
+ * Plays `keyframes` on `el` (optionally its ::backdrop) and resolves when it
33
+ * ends — at once when motion is not allowed. The final frame stays applied
34
+ * (fill: forwards) until `cancel()`: call it once the element is hidden.
35
+ * @returns {{ finished: Promise<void>, cancel: () => void }}
36
+ */
37
+ export function play(el, keyframes, { speed = "base", ease = "in", pseudoElement } = {}) {
38
+ if (!motionAllowed(el, speed)) return { finished: Promise.resolve(), cancel() {} };
39
+ const ms = duration(el, speed);
40
+ let anim;
41
+ try {
42
+ anim = el.animate(keyframes, { duration: ms, easing: easing(el, ease), fill: "forwards", ...(pseudoElement ? { pseudoElement } : {}) });
43
+ } catch {
44
+ return { finished: Promise.resolve(), cancel() {} };
45
+ }
46
+ // A throttled or suspended timeline must not keep the element open forever.
47
+ const guard = new Promise((r) => setTimeout(r, ms + 150));
48
+ return {
49
+ finished: Promise.race([anim.finished.then(() => {}, () => {}), guard]),
50
+ cancel: () => anim.cancel(),
51
+ };
52
+ }
53
+
54
+ /** Plays several animations; resolves when all ended. */
55
+ export function playAll(list) {
56
+ return { finished: Promise.all(list.map((a) => a.finished)).then(() => {}), cancel: () => list.forEach((a) => a.cancel()) };
57
+ }
58
+
59
+ /** Fades and shrinks an element to nothing (height, margins, padding), then resolves. */
60
+ export function collapseOut(el, { speed = "base" } = {}) {
61
+ const cs = getComputedStyle(el);
62
+ const h = el.getBoundingClientRect().height;
63
+ return play(el, [
64
+ { height: `${h}px`, opacity: 1, marginTop: cs.marginTop, marginBottom: cs.marginBottom, paddingTop: cs.paddingTop, paddingBottom: cs.paddingBottom, overflow: "hidden" },
65
+ { height: "0px", opacity: 0, marginTop: "0px", marginBottom: "0px", paddingTop: "0px", paddingBottom: "0px", overflow: "hidden" },
66
+ ], { speed });
67
+ }
68
+
69
+ /** The exit of a floating panel (menus, popovers, dropdowns, tooltips). */
70
+ export const POP_OUT = [
71
+ { opacity: 1, transform: "none" },
72
+ { opacity: 0, transform: "scale(0.97)" },
73
+ ];
@@ -0,0 +1,91 @@
1
+ // Anchored positioning for floating elements (popovers, menus, tooltips).
2
+ //
3
+ // The floating element is position: fixed (it lives in the top layer via the
4
+ // Popover API), placed next to an anchor rectangle, flipped to the opposite
5
+ // side when it does not fit, and shifted to stay inside the viewport.
6
+ // Done in JS rather than CSS anchor positioning, which not every webview
7
+ // ships yet (WebKitGTK, older WKWebView).
8
+
9
+ const OPPOSITE = { top: "bottom", bottom: "top", left: "right", right: "left" };
10
+
11
+ /**
12
+ * @typedef {object} PlaceOptions
13
+ * @property {string} [placement] side[-align]: top|bottom|left|right, -start|-end (default "bottom")
14
+ * @property {number} [offset] gap to the anchor in px (default 8)
15
+ * @property {number} [padding] minimum distance to the viewport edges (default 8)
16
+ * @property {boolean} [flip] try the opposite side when it does not fit (default true)
17
+ * @property {boolean} [matchWidth] at least as wide as the anchor
18
+ */
19
+
20
+ /**
21
+ * Places `floating` next to `anchor` (a DOMRect-like {left, top, right, bottom, width, height}).
22
+ * The floating element must be rendered (open) so it can be measured.
23
+ * @param {PlaceOptions} options
24
+ * @returns {string} the final placement, e.g. "top-start"
25
+ */
26
+ export function place(anchor, floating, { placement = "bottom", offset = 8, padding = 8, flip = true, matchWidth = false } = {}) {
27
+ const vw = document.documentElement.clientWidth;
28
+ const vh = window.innerHeight;
29
+ floating.style.position = "fixed";
30
+ floating.style.inset = "auto";
31
+ floating.style.margin = "0";
32
+ if (matchWidth) floating.style.minWidth = `${anchor.width}px`;
33
+ const f = floating.getBoundingClientRect();
34
+ let [side, align = "center"] = placement.split("-");
35
+
36
+ const fits = (s) => {
37
+ if (s === "bottom") return anchor.bottom + offset + f.height <= vh - padding;
38
+ if (s === "top") return anchor.top - offset - f.height >= padding;
39
+ if (s === "right") return anchor.right + offset + f.width <= vw - padding;
40
+ return anchor.left - offset - f.width >= padding;
41
+ };
42
+ if (flip && !fits(side) && fits(OPPOSITE[side])) side = OPPOSITE[side];
43
+
44
+ let x;
45
+ let y;
46
+ if (side === "top" || side === "bottom") {
47
+ y = side === "bottom" ? anchor.bottom + offset : anchor.top - offset - f.height;
48
+ x = align === "start" ? anchor.left
49
+ : align === "end" ? anchor.right - f.width
50
+ : anchor.left + anchor.width / 2 - f.width / 2;
51
+ } else {
52
+ x = side === "right" ? anchor.right + offset : anchor.left - offset - f.width;
53
+ y = align === "start" ? anchor.top
54
+ : align === "end" ? anchor.bottom - f.height
55
+ : anchor.top + anchor.height / 2 - f.height / 2;
56
+ }
57
+ // Shift into the viewport.
58
+ x = Math.max(padding, Math.min(x, vw - f.width - padding));
59
+ y = Math.max(padding, Math.min(y, vh - f.height - padding));
60
+ floating.style.left = `${Math.round(x)}px`;
61
+ floating.style.top = `${Math.round(y)}px`;
62
+ const final = `${side}-${align}`;
63
+ floating.dataset.placement = final;
64
+ return final;
65
+ }
66
+
67
+ /** A zero-size rectangle at a point (context menus). */
68
+ export function pointRect(x, y) {
69
+ return { left: x, right: x, top: y, bottom: y, width: 0, height: 0, x, y };
70
+ }
71
+
72
+ /**
73
+ * Calls `update` whenever the layout may have moved the anchor or resized
74
+ * the floating element: scroll (any ancestor), window resize, element resize.
75
+ * @returns {() => void} stops watching
76
+ */
77
+ export function autoUpdate(anchor, floating, update) {
78
+ const onChange = () => update();
79
+ window.addEventListener("scroll", onChange, { capture: true, passive: true });
80
+ window.addEventListener("resize", onChange);
81
+ const ro = typeof ResizeObserver === "function" ? new ResizeObserver(onChange) : null;
82
+ if (ro) {
83
+ if (anchor instanceof Element) ro.observe(anchor);
84
+ ro.observe(floating);
85
+ }
86
+ return () => {
87
+ window.removeEventListener("scroll", onChange, { capture: true });
88
+ window.removeEventListener("resize", onChange);
89
+ ro?.disconnect();
90
+ };
91
+ }