@raylang/elements 0.7.0 → 0.9.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/base.css +1 -1
- package/components/accordion/accordion.js +7 -4
- package/components/alert/alert.js +2 -1
- package/components/attachment/attachment.js +4 -5
- package/components/blockquote/blockquote.js +2 -2
- package/components/bottom-nav/bottom-nav.js +3 -3
- package/components/carousel/carousel.js +22 -10
- package/components/checkbox/checkbox.js +5 -2
- package/components/checkbox-group/checkbox-group.js +1 -1
- package/components/code/code.js +16 -11
- package/components/color-picker/color-picker.js +5 -5
- package/components/combobox/combobox.js +2 -1
- package/components/command-palette/command-palette.js +17 -2
- package/components/date-picker/date-picker.js +5 -3
- package/components/dialog/dialog.js +1 -1
- package/components/doc-tabs/doc-tabs.js +255 -0
- package/components/drawer/drawer.js +27 -9
- package/components/icon/icon.js +4 -1
- package/components/icon-button/icon-button.js +4 -0
- package/components/indicator/indicator.js +2 -1
- package/components/input/input.js +13 -1
- package/components/menu/menu.js +43 -8
- package/components/mode-input/mode-input.js +221 -0
- package/components/nav-link/nav-link.js +5 -2
- package/components/notification/notification.js +15 -12
- package/components/progress/progress.js +2 -2
- package/components/radio/radio.js +4 -2
- package/components/scroll-area/scroll-area.js +14 -11
- package/components/search/search.js +3 -3
- package/components/segmented-control/segmented-control.js +5 -3
- package/components/select/select.js +3 -3
- package/components/separator/separator.js +8 -3
- package/components/sidebar/sidebar.js +154 -11
- package/components/slider/slider.js +5 -2
- package/components/split/split.js +25 -7
- package/components/stepper/stepper.js +4 -4
- package/components/switch/switch.js +3 -3
- package/components/table/table.js +5 -5
- package/components/tabs/tabs.js +55 -14
- package/components/textarea/textarea.js +13 -1
- package/components/timeline/timeline.js +13 -8
- package/components/toggle-group/toggle-group.js +6 -4
- package/core/complete.js +252 -0
- package/core/core.js +6 -1
- package/core/direction.js +19 -0
- package/core/icons.js +12 -0
- package/core/position.js +11 -3
- package/core/scroll.js +85 -1
- package/core/styles.js +1 -1
- package/core/textlayer.js +214 -0
- package/core.js +1 -0
- package/custom-elements.json +1487 -143
- package/index.js +2 -0
- package/llms-full.txt +224 -28
- package/llms.txt +26 -4
- package/package.json +9 -1
- package/ray-elements.d.ts +190 -31
- package/vscode.html-custom-data.json +477 -22
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { css, define } from "../../core/core.js";
|
|
4
4
|
import { sizeStyles, radiusStyles } from "../../core/styles.js";
|
|
5
5
|
import { FieldControl, fieldStyles, boxStyles, fieldTemplate } from "../../core/form.js";
|
|
6
|
+
import { syncHighlight, textLayerStyles } from "../../core/textlayer.js";
|
|
7
|
+
import { syncComplete, completeStyles } from "../../core/complete.js";
|
|
6
8
|
|
|
7
9
|
const styles = css`
|
|
8
10
|
.box { align-items: stretch; }
|
|
@@ -26,8 +28,11 @@ export class RayTextarea extends FieldControl {
|
|
|
26
28
|
autosize: { type: Boolean, reflect: true },
|
|
27
29
|
maxRows: { type: Number },
|
|
28
30
|
readonly: { type: Boolean, reflect: true },
|
|
31
|
+
highlight: { attribute: false },
|
|
32
|
+
complete: { attribute: false },
|
|
33
|
+
suggestionsLabel: { type: String, default: "Suggestions" },
|
|
29
34
|
};
|
|
30
|
-
static styles = [sizeStyles, radiusStyles, fieldStyles, boxStyles, styles];
|
|
35
|
+
static styles = [sizeStyles, radiusStyles, fieldStyles, boxStyles, textLayerStyles, completeStyles, styles];
|
|
31
36
|
static template = fieldTemplate(`<textarea id="control" class="control" part="textarea"></textarea>`, { sections: false });
|
|
32
37
|
|
|
33
38
|
static get observedAttributes() {
|
|
@@ -68,6 +73,13 @@ export class RayTextarea extends FieldControl {
|
|
|
68
73
|
this.syncField();
|
|
69
74
|
this._commit();
|
|
70
75
|
this._fit();
|
|
76
|
+
syncHighlight(this);
|
|
77
|
+
syncComplete(this);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Runs `highlight` again (what it marks changed, not the text). */
|
|
81
|
+
refreshHighlight() {
|
|
82
|
+
syncHighlight(this);
|
|
71
83
|
}
|
|
72
84
|
|
|
73
85
|
_commit() {
|
|
@@ -22,9 +22,14 @@ const itemStyles = css`
|
|
|
22
22
|
}
|
|
23
23
|
:host([hidden]) { display: none; }
|
|
24
24
|
:host([data-last]) { padding-bottom: 0; }
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
:host([data-align="
|
|
25
|
+
/* The line at the end: end, or left/right as sides of the screen (the
|
|
26
|
+
grid's columns follow the writing direction). */
|
|
27
|
+
:host([data-align="end"]), :host([data-align="right"]:dir(ltr)), :host([data-align="left"]:dir(rtl)) {
|
|
28
|
+
grid-template-columns: minmax(0, 1fr) var(--_bullet);
|
|
29
|
+
text-align: end;
|
|
30
|
+
}
|
|
31
|
+
:host([data-align="end"]) .bullet-col, :host([data-align="right"]:dir(ltr)) .bullet-col, :host([data-align="left"]:dir(rtl)) .bullet-col { grid-column: 2; grid-row: 1; }
|
|
32
|
+
:host([data-align="end"]) .body, :host([data-align="right"]:dir(ltr)) .body, :host([data-align="left"]:dir(rtl)) .body { grid-column: 1; grid-row: 1; }
|
|
28
33
|
.bullet-col { position: relative; display: flex; justify-content: center; }
|
|
29
34
|
.bullet {
|
|
30
35
|
position: relative;
|
|
@@ -47,12 +52,12 @@ const itemStyles = css`
|
|
|
47
52
|
position: absolute;
|
|
48
53
|
top: var(--_bullet);
|
|
49
54
|
bottom: calc(-1 * var(--ray-space-6));
|
|
50
|
-
left: calc(50% - var(--_line) / 2);
|
|
55
|
+
left: calc(50% - var(--_line) / 2); /* physical */
|
|
51
56
|
width: 0;
|
|
52
|
-
border-left: var(--_line) var(--_line-style, solid) var(--ray-border);
|
|
57
|
+
border-left: var(--_line) var(--_line-style, solid) var(--ray-border); /* physical */
|
|
53
58
|
}
|
|
54
59
|
:host([data-last]) .line { display: none; }
|
|
55
|
-
:host([data-line-reached]) .line { border-left-color: var(--_filled); }
|
|
60
|
+
:host([data-line-reached]) .line { border-left-color: var(--_filled); } /* physical */
|
|
56
61
|
:host([line-variant="dashed"]) .line { --_line-style: dashed; }
|
|
57
62
|
:host([line-variant="dotted"]) .line { --_line-style: dotted; }
|
|
58
63
|
.body { min-width: 0; padding-top: calc((var(--_bullet) - 1.4em) / 2); }
|
|
@@ -67,7 +72,7 @@ const itemStyles = css`
|
|
|
67
72
|
export class RayTimeline extends RayElement {
|
|
68
73
|
static properties = {
|
|
69
74
|
active: { type: Number, reflect: true, default: -1 },
|
|
70
|
-
align: { type: String, reflect: true, default: "
|
|
75
|
+
align: { type: String, reflect: true, default: "start" },
|
|
71
76
|
bulletSize: { type: String, attribute: "bullet-size" },
|
|
72
77
|
lineWidth: { type: Number, attribute: "line-width" },
|
|
73
78
|
color: { type: String, reflect: true },
|
|
@@ -106,7 +111,7 @@ export class RayTimeline extends RayElement {
|
|
|
106
111
|
item.toggleAttribute("data-reached", active >= 0 && k <= active);
|
|
107
112
|
item.toggleAttribute("data-line-reached", active >= 0 && (this.reverseActive ? k <= active && k > 0 : k < active));
|
|
108
113
|
item.toggleAttribute("data-last", i === n - 1);
|
|
109
|
-
item.setAttribute("data-align",
|
|
114
|
+
item.setAttribute("data-align", ["end", "left", "right"].includes(this.align) ? this.align : "start");
|
|
110
115
|
});
|
|
111
116
|
}
|
|
112
117
|
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// </ray-toggle-group>
|
|
14
14
|
|
|
15
15
|
import { css, define } from "../../core/core.js";
|
|
16
|
+
import { inlineKey } from "../../core/direction.js";
|
|
16
17
|
import { RayButtonGroup } from "../button-group/button-group.js";
|
|
17
18
|
import "../toggle/toggle.js";
|
|
18
19
|
|
|
@@ -113,14 +114,15 @@ export class RayToggleGroup extends RayButtonGroup {
|
|
|
113
114
|
const vertical = this.orientation === "vertical";
|
|
114
115
|
const next = vertical ? "ArrowDown" : "ArrowRight";
|
|
115
116
|
const prev = vertical ? "ArrowUp" : "ArrowLeft";
|
|
116
|
-
|
|
117
|
+
const key = vertical ? e.key : inlineKey(e.key, this);
|
|
118
|
+
if (![next, prev, "Home", "End"].includes(key)) return;
|
|
117
119
|
const items = this.toggles.filter((t) => !t.disabled);
|
|
118
120
|
if (items.length === 0) return;
|
|
119
121
|
const current = items.findIndex((t) => t.matches(":focus-within"));
|
|
120
122
|
let i = current;
|
|
121
|
-
if (
|
|
122
|
-
else if (
|
|
123
|
-
else if (
|
|
123
|
+
if (key === "Home") i = 0;
|
|
124
|
+
else if (key === "End") i = items.length - 1;
|
|
125
|
+
else if (key === next) i = (current + 1) % items.length;
|
|
124
126
|
else i = (current - 1 + items.length) % items.length;
|
|
125
127
|
e.preventDefault();
|
|
126
128
|
this.#rove(items[i]);
|
package/core/complete.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Inline suggestions for a text field (ray-input, ray-textarea): while the
|
|
2
|
+
// user types, `host.complete(text, caret)` says which part of the text a
|
|
3
|
+
// suggestion would replace and with what ({ from, to, items }, or null);
|
|
4
|
+
// a listbox opens under that part, in the top layer, and the chosen item
|
|
5
|
+
// replaces it as an edit (undoable, an ordinary input event).
|
|
6
|
+
//
|
|
7
|
+
// The combobox pattern: the control gets aria-autocomplete, aria-expanded,
|
|
8
|
+
// aria-controls and aria-activedescendant; the listbox lives in the same
|
|
9
|
+
// shadow root, so the ids work. ArrowUp/Down move, Enter or Tab accept,
|
|
10
|
+
// Escape closes, Ctrl+Space asks; the keys are taken before the control's
|
|
11
|
+
// own (Enter submitting, Escape closing a dialog) only while the list is
|
|
12
|
+
// open.
|
|
13
|
+
|
|
14
|
+
import { css, uid } from "./core.js";
|
|
15
|
+
import { TextLayer, syncHighlight } from "./textlayer.js";
|
|
16
|
+
|
|
17
|
+
export const completeStyles = css`
|
|
18
|
+
.suggestions {
|
|
19
|
+
position: fixed;
|
|
20
|
+
inset: auto;
|
|
21
|
+
box-sizing: border-box;
|
|
22
|
+
min-width: 12rem;
|
|
23
|
+
max-width: min(28rem, calc(100vw - 16px));
|
|
24
|
+
max-height: 14rem;
|
|
25
|
+
margin: 0;
|
|
26
|
+
padding: var(--ray-space-1);
|
|
27
|
+
overflow-y: auto;
|
|
28
|
+
font-size: var(--ray-font-size-sm);
|
|
29
|
+
color: var(--ray-popover-foreground);
|
|
30
|
+
background: var(--ray-popover);
|
|
31
|
+
border: 1px solid var(--ray-border);
|
|
32
|
+
border-radius: var(--ray-radius-default);
|
|
33
|
+
box-shadow: var(--ray-shadow-md);
|
|
34
|
+
}
|
|
35
|
+
.suggestion {
|
|
36
|
+
display: flex;
|
|
37
|
+
align-items: baseline;
|
|
38
|
+
gap: var(--ray-space-3);
|
|
39
|
+
padding: var(--ray-space-1) var(--ray-space-2);
|
|
40
|
+
border-radius: var(--ray-radius-sm);
|
|
41
|
+
cursor: default;
|
|
42
|
+
white-space: nowrap;
|
|
43
|
+
}
|
|
44
|
+
.suggestion[aria-selected="true"] { color: var(--ray-accent-foreground); background: var(--ray-accent); }
|
|
45
|
+
.suggestion .s-label { flex: none; font-family: var(--ray-font-mono); }
|
|
46
|
+
.suggestion .s-detail { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--ray-muted-foreground); }
|
|
47
|
+
.suggestion .s-description { display: block; font-size: var(--ray-font-size-xs); color: var(--ray-muted-foreground); }
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
/** The layer of a field, made once (shared with highlight). */
|
|
51
|
+
export function layerOf(host) {
|
|
52
|
+
host._textLayer ??= new TextLayer(host.control);
|
|
53
|
+
return host._textLayer;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class Completer {
|
|
57
|
+
constructor(host) {
|
|
58
|
+
this.host = host;
|
|
59
|
+
const c = (this.control = host.control);
|
|
60
|
+
this.list = document.createElement("div");
|
|
61
|
+
this.list.className = "suggestions";
|
|
62
|
+
this.list.id = uid("suggestions");
|
|
63
|
+
this.list.setAttribute("part", "suggestions");
|
|
64
|
+
this.list.setAttribute("role", "listbox");
|
|
65
|
+
this.list.popover = "manual";
|
|
66
|
+
host.shadowRoot.append(this.list);
|
|
67
|
+
this.open = null; // { items, index, from, to }
|
|
68
|
+
this.asked = 0;
|
|
69
|
+
this.inserting = false;
|
|
70
|
+
c.setAttribute("aria-autocomplete", "list");
|
|
71
|
+
c.setAttribute("aria-expanded", "false");
|
|
72
|
+
c.setAttribute("aria-controls", this.list.id);
|
|
73
|
+
if (c.localName === "input") c.setAttribute("role", "combobox");
|
|
74
|
+
// Choosing with the pointer keeps the focus (and the caret) in the field.
|
|
75
|
+
this.list.addEventListener("pointerdown", (e) => e.preventDefault());
|
|
76
|
+
this.list.addEventListener("click", (e) => {
|
|
77
|
+
const option = e.target.closest(".suggestion");
|
|
78
|
+
if (option) this.accept(Number(option.dataset.index));
|
|
79
|
+
});
|
|
80
|
+
c.addEventListener("input", () => {
|
|
81
|
+
if (!this.inserting) this.ask();
|
|
82
|
+
});
|
|
83
|
+
c.addEventListener("click", () => this.open && this.ask());
|
|
84
|
+
c.addEventListener("blur", () => this.close());
|
|
85
|
+
// The caret moved along the text (on keyup it has landed): ask where it is now.
|
|
86
|
+
c.addEventListener("keyup", (e) => {
|
|
87
|
+
if (this.open && ["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) this.ask();
|
|
88
|
+
});
|
|
89
|
+
// Captured on the host: before the control's Enter and a dialog's Escape.
|
|
90
|
+
host.addEventListener("keydown", (e) => this.key(e), { capture: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Calls complete() for the text and caret now; shows or closes the list. */
|
|
94
|
+
async ask() {
|
|
95
|
+
const c = this.control;
|
|
96
|
+
const fn = this.host.complete;
|
|
97
|
+
if (typeof fn !== "function" || c.selectionStart !== c.selectionEnd || c.readOnly || c.disabled) {
|
|
98
|
+
this.close();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const n = ++this.asked;
|
|
102
|
+
let result = null;
|
|
103
|
+
try {
|
|
104
|
+
result = await fn(c.value, c.selectionStart);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error("ray-elements: complete() threw", err);
|
|
107
|
+
}
|
|
108
|
+
// A later keystroke asked again, or the field lost the focus meanwhile.
|
|
109
|
+
if (n !== this.asked || this.host.shadowRoot.activeElement !== c) return;
|
|
110
|
+
const items = Array.isArray(result?.items) ? result.items.filter((i) => i && i.value !== undefined) : [];
|
|
111
|
+
if (!items.length) {
|
|
112
|
+
this.close();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const length = c.value.length;
|
|
116
|
+
const from = Math.max(0, Math.min(length, Math.trunc(result.from ?? c.selectionStart)));
|
|
117
|
+
const to = Math.max(from, Math.min(length, Math.trunc(result.to ?? c.selectionStart)));
|
|
118
|
+
this.show(items, from, to);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
show(items, from, to) {
|
|
122
|
+
this.list.setAttribute("aria-label", this.host.suggestionsLabel ?? "Suggestions");
|
|
123
|
+
this.list.replaceChildren(...items.map((item, i) => {
|
|
124
|
+
const option = document.createElement("div");
|
|
125
|
+
option.className = "suggestion";
|
|
126
|
+
option.id = `${this.list.id}-${i}`;
|
|
127
|
+
option.dataset.index = String(i);
|
|
128
|
+
option.setAttribute("role", "option");
|
|
129
|
+
option.setAttribute("part", "suggestion");
|
|
130
|
+
const label = document.createElement("span");
|
|
131
|
+
label.className = "s-label";
|
|
132
|
+
label.textContent = item.label ?? String(item.value);
|
|
133
|
+
option.append(label);
|
|
134
|
+
if (item.detail) {
|
|
135
|
+
const detail = document.createElement("span");
|
|
136
|
+
detail.className = "s-detail";
|
|
137
|
+
detail.textContent = item.detail;
|
|
138
|
+
option.append(detail);
|
|
139
|
+
}
|
|
140
|
+
if (item.description) {
|
|
141
|
+
const d = document.createElement("span");
|
|
142
|
+
d.className = "s-description";
|
|
143
|
+
d.textContent = item.description;
|
|
144
|
+
label.append(d);
|
|
145
|
+
}
|
|
146
|
+
return option;
|
|
147
|
+
}));
|
|
148
|
+
const keep = this.open ? this.open.items[this.open.index]?.value : undefined;
|
|
149
|
+
const index = Math.max(0, items.findIndex((i) => i.value === keep));
|
|
150
|
+
this.open = { items, index, from, to };
|
|
151
|
+
if (!this.list.matches(":popover-open")) this.list.showPopover();
|
|
152
|
+
this.control.setAttribute("aria-expanded", "true");
|
|
153
|
+
this.select(index);
|
|
154
|
+
this.place();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Under the text it would replace; above it when there is no room below.
|
|
158
|
+
place() {
|
|
159
|
+
const at = layerOf(this.host);
|
|
160
|
+
// The layer's text as it is now (it is otherwise repainted on update).
|
|
161
|
+
syncHighlight(this.host);
|
|
162
|
+
const r = at.rectAt(this.open.from);
|
|
163
|
+
// A one-line field: below its box, not inside it; a textarea: below the line.
|
|
164
|
+
const box = at.multi ? r : this.control.parentElement.getBoundingClientRect();
|
|
165
|
+
const w = this.list.offsetWidth;
|
|
166
|
+
const h = this.list.offsetHeight;
|
|
167
|
+
const gap = 4;
|
|
168
|
+
const left = Math.max(8, Math.min(r.left, innerWidth - w - 8));
|
|
169
|
+
const below = box.bottom + gap;
|
|
170
|
+
const top = below + h <= innerHeight - 8 ? below : Math.max(8, box.top - gap - h);
|
|
171
|
+
this.list.style.left = `${left}px`;
|
|
172
|
+
this.list.style.top = `${top}px`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
select(index) {
|
|
176
|
+
this.open.index = index;
|
|
177
|
+
const options = [...this.list.children];
|
|
178
|
+
options.forEach((o, i) => o.setAttribute("aria-selected", String(i === index)));
|
|
179
|
+
const current = options[index];
|
|
180
|
+
if (!current) return;
|
|
181
|
+
this.control.setAttribute("aria-activedescendant", current.id);
|
|
182
|
+
const top = current.offsetTop;
|
|
183
|
+
if (top < this.list.scrollTop) this.list.scrollTop = top;
|
|
184
|
+
else if (top + current.offsetHeight > this.list.scrollTop + this.list.clientHeight) this.list.scrollTop = top + current.offsetHeight - this.list.clientHeight;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
close() {
|
|
188
|
+
this.asked++;
|
|
189
|
+
if (!this.open) return;
|
|
190
|
+
this.open = null;
|
|
191
|
+
this.control.setAttribute("aria-expanded", "false");
|
|
192
|
+
this.control.removeAttribute("aria-activedescendant");
|
|
193
|
+
if (this.list.matches(":popover-open")) this.list.hidePopover();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Replaces from–to with the item's value, as an edit. */
|
|
197
|
+
accept(index) {
|
|
198
|
+
const { items, from, to } = this.open ?? {};
|
|
199
|
+
const item = items?.[index];
|
|
200
|
+
this.close();
|
|
201
|
+
if (!item) return;
|
|
202
|
+
const c = this.control;
|
|
203
|
+
const text = String(item.value);
|
|
204
|
+
c.focus();
|
|
205
|
+
c.setSelectionRange(from, to);
|
|
206
|
+
this.inserting = true;
|
|
207
|
+
try {
|
|
208
|
+
// execCommand edits the focused element and keeps undo; without the
|
|
209
|
+
// focus (a window in the background) it may do nothing: set it then.
|
|
210
|
+
const focused = this.host.shadowRoot.activeElement === c;
|
|
211
|
+
if (!focused || !document.execCommand("insertText", false, text) || c.value.slice(from, from + text.length) !== text) {
|
|
212
|
+
c.value = c.value.slice(0, from) + text + c.value.slice(to);
|
|
213
|
+
c.setSelectionRange(from + text.length, from + text.length);
|
|
214
|
+
c.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
|
|
215
|
+
}
|
|
216
|
+
} finally {
|
|
217
|
+
this.inserting = false;
|
|
218
|
+
}
|
|
219
|
+
this.host.emit("ray-complete", { item, from, to });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
key(e) {
|
|
223
|
+
if (e.composedPath()[0] !== this.control) return;
|
|
224
|
+
if (e.key === " " && e.ctrlKey) {
|
|
225
|
+
e.preventDefault();
|
|
226
|
+
this.ask();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (!this.open) return;
|
|
230
|
+
const n = this.open.items.length;
|
|
231
|
+
const take = () => {
|
|
232
|
+
e.preventDefault();
|
|
233
|
+
e.stopPropagation();
|
|
234
|
+
};
|
|
235
|
+
switch (e.key) {
|
|
236
|
+
case "ArrowDown": take(); this.select((this.open.index + 1) % n); break;
|
|
237
|
+
case "ArrowUp": take(); this.select((this.open.index - 1 + n) % n); break;
|
|
238
|
+
case "Enter":
|
|
239
|
+
case "Tab": take(); this.accept(this.open.index); break;
|
|
240
|
+
case "Escape": take(); this.close(); break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Sets up inline suggestions on `host` (ray-input, ray-textarea) the first
|
|
247
|
+
* time it has a `complete` function; closes the list when it is removed.
|
|
248
|
+
*/
|
|
249
|
+
export function syncComplete(host) {
|
|
250
|
+
if (typeof host.complete === "function") host._completer ??= new Completer(host);
|
|
251
|
+
else host._completer?.close();
|
|
252
|
+
}
|
package/core/core.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the theme API. No dependencies; loads as a plain ES module (also from
|
|
3
3
|
// ray://app in a std/ui window).
|
|
4
4
|
|
|
5
|
-
import { getIcon, onIconsChange } from "./icons.js";
|
|
5
|
+
import { getIcon, mirrorsInRtl, onIconsChange } from "./icons.js";
|
|
6
6
|
import { parseColor, shades, contrastRatio, toHex, toOklch } from "./color.js";
|
|
7
7
|
|
|
8
8
|
export { setIcons, getIcon, iconNames, iconAliases } from "./icons.js";
|
|
@@ -59,6 +59,8 @@ export const hostStyles = css`
|
|
|
59
59
|
height: var(--ray-icon-size, 1em);
|
|
60
60
|
stroke-width: var(--ray-icon-stroke, 2);
|
|
61
61
|
}
|
|
62
|
+
/* Right to left, the roles that point along the text. */
|
|
63
|
+
:where([data-ray-icon-mirror]):dir(rtl) > svg { transform: scaleX(-1); }
|
|
62
64
|
.focus-ring:focus-visible {
|
|
63
65
|
outline: var(--ray-focus-ring-width) solid var(--ray-ring);
|
|
64
66
|
outline-offset: var(--ray-focus-ring-offset);
|
|
@@ -315,11 +317,14 @@ export class RayElement extends HTMLElement {
|
|
|
315
317
|
* it for markup they create after the template. An empty name or "none"
|
|
316
318
|
* (the value components use to hide an icon) leaves the slot empty;
|
|
317
319
|
* data-ray-icon-filled asks for the filled version (the outline without one).
|
|
320
|
+
* A role that points along the text (ui.next…) gets data-ray-icon-mirror,
|
|
321
|
+
* which mirrors it under dir="rtl".
|
|
318
322
|
*/
|
|
319
323
|
paintIcons(root = this.shadowRoot) {
|
|
320
324
|
for (const slot of root.querySelectorAll("[data-ray-icon]")) {
|
|
321
325
|
const name = slot.getAttribute("data-ray-icon");
|
|
322
326
|
slot.innerHTML = name && name !== "none" ? getIcon(name, { filled: slot.hasAttribute("data-ray-icon-filled") }) : "";
|
|
327
|
+
slot.toggleAttribute("data-ray-icon-mirror", mirrorsInRtl(name));
|
|
323
328
|
}
|
|
324
329
|
}
|
|
325
330
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The writing direction, for what CSS does not mirror by itself: arrow keys
|
|
2
|
+
// and pointer or scroll coordinates. Read when used (not cached), so a
|
|
3
|
+
// change of dir takes effect at once.
|
|
4
|
+
|
|
5
|
+
/** Whether `el` lays out right to left (dir="rtl" on it or an ancestor). */
|
|
6
|
+
export function isRtl(el) {
|
|
7
|
+
return getComputedStyle(el).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An arrow key as the reading direction sees it. Right to left, ArrowLeft
|
|
12
|
+
* moves forward, so it comes back as "ArrowRight", and ArrowRight as
|
|
13
|
+
* "ArrowLeft"; any other key as it is.
|
|
14
|
+
* if (inlineKey(e.key, this) === "ArrowRight") this.next();
|
|
15
|
+
*/
|
|
16
|
+
export function inlineKey(key, el) {
|
|
17
|
+
if ((key === "ArrowLeft" || key === "ArrowRight") && isRtl(el)) return key === "ArrowLeft" ? "ArrowRight" : "ArrowLeft";
|
|
18
|
+
return key;
|
|
19
|
+
}
|
package/core/icons.js
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
// setIcons({ close: … }) still changes every × and setIcons({ "ui.remove": … })
|
|
14
14
|
// only the chips'.
|
|
15
15
|
//
|
|
16
|
+
// The roles that point along the text (ui.next, ui.previous, ui.disclosure…;
|
|
17
|
+
// MIRRORED) are drawn mirrored in a right-to-left context, the glyph or the
|
|
18
|
+
// app's own icon alike: the next page is to the left there.
|
|
19
|
+
//
|
|
16
20
|
// An icon may have a filled version, "<name>:filled", for a state: the active
|
|
17
21
|
// tab, a favorite, unread. <ray-icon name="star" filled> draws it, and the
|
|
18
22
|
// outline one when there is none. setIcons({ "star:filled": … }) adds one.
|
|
@@ -131,6 +135,14 @@ const ALIASES = {
|
|
|
131
135
|
"ui.trend-flat": "minus",
|
|
132
136
|
};
|
|
133
137
|
|
|
138
|
+
/** The roles that point along the text: mirrored in a right-to-left context. */
|
|
139
|
+
const MIRRORED = new Set(["ui.disclosure", "ui.previous", "ui.next", "ui.first", "ui.last", "ui.separator"]);
|
|
140
|
+
|
|
141
|
+
/** Whether an icon is drawn mirrored right to left (a role that points along the text). */
|
|
142
|
+
export function mirrorsInRtl(name) {
|
|
143
|
+
return MIRRORED.has(name);
|
|
144
|
+
}
|
|
145
|
+
|
|
134
146
|
const registry = new Map([...Object.entries(DEFAULTS), ...Object.entries(FILLED).map(([name, markup]) => [name + FILLED_SUFFIX, markup])]);
|
|
135
147
|
const listeners = new Set();
|
|
136
148
|
const warned = new Set();
|
package/core/position.js
CHANGED
|
@@ -5,12 +5,19 @@
|
|
|
5
5
|
// side when it does not fit, and shifted to stay inside the viewport.
|
|
6
6
|
// Done in JS rather than CSS anchor positioning, which not every webview
|
|
7
7
|
// ships yet (WebKitGTK, older WKWebView).
|
|
8
|
+
//
|
|
9
|
+
// The sides top/bottom/left/right are sides of the screen; the alignments
|
|
10
|
+
// -start/-end follow the writing direction of the floating element: right to
|
|
11
|
+
// left, "bottom-start" lines up the right edges.
|
|
12
|
+
|
|
13
|
+
import { isRtl } from "./direction.js";
|
|
8
14
|
|
|
9
15
|
const OPPOSITE = { top: "bottom", bottom: "top", left: "right", right: "left" };
|
|
10
16
|
|
|
11
17
|
/**
|
|
12
18
|
* @typedef {object} PlaceOptions
|
|
13
|
-
* @property {string} [placement] side[-align]: top|bottom|left|right, -start|-end (default "bottom")
|
|
19
|
+
* @property {string} [placement] side[-align]: top|bottom|left|right, -start|-end (default "bottom");
|
|
20
|
+
* on top/bottom, -start/-end follow the writing direction
|
|
14
21
|
* @property {number} [offset] gap to the anchor in px (default 8)
|
|
15
22
|
* @property {number} [padding] minimum distance to the viewport edges (default 8)
|
|
16
23
|
* @property {boolean} [flip] try the opposite side when it does not fit (default true)
|
|
@@ -45,8 +52,9 @@ export function place(anchor, floating, { placement = "bottom", offset = 8, padd
|
|
|
45
52
|
let y;
|
|
46
53
|
if (side === "top" || side === "bottom") {
|
|
47
54
|
y = side === "bottom" ? anchor.bottom + offset : anchor.top - offset - f.height;
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
const rtl = align !== "center" && isRtl(floating);
|
|
56
|
+
x = align === (rtl ? "end" : "start") ? anchor.left
|
|
57
|
+
: align === (rtl ? "start" : "end") ? anchor.right - f.width
|
|
50
58
|
: anchor.left + anchor.width / 2 - f.width / 2;
|
|
51
59
|
} else {
|
|
52
60
|
x = side === "right" ? anchor.right + offset : anchor.left - offset - f.width;
|
package/core/scroll.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Which element scrolls the page an element sits on, and the direction of
|
|
2
2
|
// its scrolling: for the bars that hide while scrolling down and come back
|
|
3
|
-
// on the way up (ray-app-bar, ray-bottom-nav).
|
|
3
|
+
// on the way up (ray-app-bar, ray-bottom-nav); and rows that scroll sideways.
|
|
4
|
+
|
|
5
|
+
import { css } from "./core.js";
|
|
6
|
+
import { isRtl } from "./direction.js";
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* The scroller of `el`: `target` (an element or a selector) when given,
|
|
@@ -56,3 +59,84 @@ export function watchScroll(scroller, cb, { tolerance = 8 } = {}) {
|
|
|
56
59
|
cancelAnimationFrame(frame);
|
|
57
60
|
};
|
|
58
61
|
}
|
|
62
|
+
|
|
63
|
+
// --- a row that scrolls sideways (tab bars) -----------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The look of a SideScroller's row (class "list"): the edge that hides items
|
|
67
|
+
* fades out, following the writing direction.
|
|
68
|
+
*/
|
|
69
|
+
export const sideScrollerStyles = css`
|
|
70
|
+
.list { --_fade-start: 0px; --_fade-end: 0px; --_fade-dir: to right; }
|
|
71
|
+
.list:dir(rtl) { --_fade-dir: to left; }
|
|
72
|
+
:host([data-overflow-start]) .list { --_fade-start: 2.5rem; }
|
|
73
|
+
:host([data-overflow-end]) .list { --_fade-end: 2.5rem; }
|
|
74
|
+
:host([data-overflow-start]) .list, :host([data-overflow-end]) .list {
|
|
75
|
+
-webkit-mask-image: linear-gradient(var(--_fade-dir), transparent, #000 var(--_fade-start), #000 calc(100% - var(--_fade-end)), transparent);
|
|
76
|
+
mask-image: linear-gradient(var(--_fade-dir), transparent, #000 var(--_fade-start), #000 calc(100% - var(--_fade-end)), transparent);
|
|
77
|
+
}
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A row of items that scrolls sideways (ray-tabs, ray-doc-tabs): it marks
|
|
82
|
+
* `host` with data-overflow-start / data-overflow-end (following the writing
|
|
83
|
+
* direction), turns a vertical wheel into a sideways scroll while the row
|
|
84
|
+
* can move (at its ends the page scrolls), and scrolls an item into view,
|
|
85
|
+
* moving the row only. `active()` false (a vertical tab list) turns it off;
|
|
86
|
+
* `onResize` runs when the row's size changes.
|
|
87
|
+
*/
|
|
88
|
+
export class SideScroller {
|
|
89
|
+
constructor(host, row, { active = () => true, onResize = () => {} } = {}) {
|
|
90
|
+
this.host = host;
|
|
91
|
+
this.row = row;
|
|
92
|
+
this.active = active;
|
|
93
|
+
row.addEventListener("scroll", () => this.update(), { passive: true });
|
|
94
|
+
row.addEventListener("wheel", (e) => this.#wheel(e), { passive: false });
|
|
95
|
+
this.ro = typeof ResizeObserver === "function" ? new ResizeObserver(() => onResize()) : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
connect() {
|
|
99
|
+
this.ro?.observe(this.row);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
disconnect() {
|
|
103
|
+
this.ro?.disconnect();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** How far the row is scrolled from its start, and how much is left to its end. */
|
|
107
|
+
scrolled() {
|
|
108
|
+
// Right to left, scrollLeft runs from 0 to negative.
|
|
109
|
+
const start = Math.abs(this.row.scrollLeft);
|
|
110
|
+
return { start, end: this.row.scrollWidth - this.row.clientWidth - start };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Sets the host's overflow marks. */
|
|
114
|
+
update() {
|
|
115
|
+
const on = this.active();
|
|
116
|
+
const { start, end } = this.scrolled();
|
|
117
|
+
this.host.toggleAttribute("data-overflow-start", on && start > 1);
|
|
118
|
+
this.host.toggleAttribute("data-overflow-end", on && end > 1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Scrolls the row to show `el`; false before layout (try again later). */
|
|
122
|
+
reveal(el) {
|
|
123
|
+
if (!el || !this.active()) return true;
|
|
124
|
+
const r = this.row.getBoundingClientRect();
|
|
125
|
+
const e = el.getBoundingClientRect();
|
|
126
|
+
if (!r.width) return false;
|
|
127
|
+
if (e.left < r.left) this.row.scrollLeft -= r.left - e.left;
|
|
128
|
+
else if (e.right > r.right) this.row.scrollLeft += e.right - r.right;
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#wheel(e) {
|
|
133
|
+
if (!this.active() || e.ctrlKey || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
|
134
|
+
const forward = e.deltaY > 0;
|
|
135
|
+
const { start, end } = this.scrolled();
|
|
136
|
+
if (forward ? end <= 1 : start <= 1) return;
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
const step = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
|
|
139
|
+
this.row.scrollLeft += isRtl(this.host) ? -step : step;
|
|
140
|
+
this.update();
|
|
141
|
+
}
|
|
142
|
+
}
|
package/core/styles.js
CHANGED
|
@@ -154,7 +154,7 @@ export const loaderStyles = css`
|
|
|
154
154
|
width: var(--_loader, 1em);
|
|
155
155
|
height: var(--_loader, 1em);
|
|
156
156
|
border: max(2px, calc(var(--_loader, 1em) / 8)) solid currentColor;
|
|
157
|
-
border-right-color: transparent;
|
|
157
|
+
border-right-color: transparent; /* physical */
|
|
158
158
|
border-radius: 50%;
|
|
159
159
|
animation: ray-spin 0.75s linear infinite;
|
|
160
160
|
}
|