@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.
Files changed (58) hide show
  1. package/base.css +1 -1
  2. package/components/accordion/accordion.js +7 -4
  3. package/components/alert/alert.js +2 -1
  4. package/components/attachment/attachment.js +4 -5
  5. package/components/blockquote/blockquote.js +2 -2
  6. package/components/bottom-nav/bottom-nav.js +3 -3
  7. package/components/carousel/carousel.js +22 -10
  8. package/components/checkbox/checkbox.js +5 -2
  9. package/components/checkbox-group/checkbox-group.js +1 -1
  10. package/components/code/code.js +16 -11
  11. package/components/color-picker/color-picker.js +5 -5
  12. package/components/combobox/combobox.js +2 -1
  13. package/components/command-palette/command-palette.js +17 -2
  14. package/components/date-picker/date-picker.js +5 -3
  15. package/components/dialog/dialog.js +1 -1
  16. package/components/doc-tabs/doc-tabs.js +255 -0
  17. package/components/drawer/drawer.js +27 -9
  18. package/components/icon/icon.js +4 -1
  19. package/components/icon-button/icon-button.js +4 -0
  20. package/components/indicator/indicator.js +2 -1
  21. package/components/input/input.js +13 -1
  22. package/components/menu/menu.js +43 -8
  23. package/components/mode-input/mode-input.js +221 -0
  24. package/components/nav-link/nav-link.js +5 -2
  25. package/components/notification/notification.js +15 -12
  26. package/components/progress/progress.js +2 -2
  27. package/components/radio/radio.js +4 -2
  28. package/components/scroll-area/scroll-area.js +14 -11
  29. package/components/search/search.js +3 -3
  30. package/components/segmented-control/segmented-control.js +5 -3
  31. package/components/select/select.js +3 -3
  32. package/components/separator/separator.js +8 -3
  33. package/components/sidebar/sidebar.js +154 -11
  34. package/components/slider/slider.js +5 -2
  35. package/components/split/split.js +25 -7
  36. package/components/stepper/stepper.js +4 -4
  37. package/components/switch/switch.js +3 -3
  38. package/components/table/table.js +5 -5
  39. package/components/tabs/tabs.js +55 -14
  40. package/components/textarea/textarea.js +13 -1
  41. package/components/timeline/timeline.js +13 -8
  42. package/components/toggle-group/toggle-group.js +6 -4
  43. package/core/complete.js +252 -0
  44. package/core/core.js +6 -1
  45. package/core/direction.js +19 -0
  46. package/core/icons.js +12 -0
  47. package/core/position.js +11 -3
  48. package/core/scroll.js +85 -1
  49. package/core/styles.js +1 -1
  50. package/core/textlayer.js +214 -0
  51. package/core.js +1 -0
  52. package/custom-elements.json +1487 -143
  53. package/index.js +2 -0
  54. package/llms-full.txt +224 -28
  55. package/llms.txt +26 -4
  56. package/package.json +9 -1
  57. package/ray-elements.d.ts +190 -31
  58. 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
- :host([data-align="right"]) { grid-template-columns: minmax(0, 1fr) var(--_bullet); text-align: right; }
26
- :host([data-align="right"]) .bullet-col { grid-column: 2; grid-row: 1; }
27
- :host([data-align="right"]) .body { grid-column: 1; grid-row: 1; }
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: "left" },
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", this.align === "right" ? "right" : "left");
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
- if (![next, prev, "Home", "End"].includes(e.key)) return;
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 (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;
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]);
@@ -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
- x = align === "start" ? anchor.left
49
- : align === "end" ? anchor.right - f.width
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
  }