@raylang/elements 0.8.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/components/code/code.js +8 -5
- package/components/command-palette/command-palette.js +16 -1
- package/components/doc-tabs/doc-tabs.js +255 -0
- package/components/icon-button/icon-button.js +4 -0
- package/components/input/input.js +13 -1
- package/components/menu/menu.js +42 -7
- package/components/mode-input/mode-input.js +221 -0
- package/components/sidebar/sidebar.js +140 -5
- package/components/split/split.js +13 -1
- package/components/tabs/tabs.js +44 -6
- package/components/textarea/textarea.js +13 -1
- package/core/complete.js +252 -0
- package/core/scroll.js +85 -1
- package/core/textlayer.js +214 -0
- package/custom-elements.json +1414 -88
- package/index.js +2 -0
- package/llms-full.txt +184 -12
- package/llms.txt +7 -3
- package/package.json +9 -1
- package/ray-elements.d.ts +158 -6
- package/vscode.html-custom-data.json +397 -4
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// <ray-mode-input>: a text field with a menu of modes on one side, like
|
|
2
|
+
// ray-password-input's toggle but with any number of choices. Choosing a
|
|
3
|
+
// mode applies its attributes to the field (type, placeholder, inputmode,
|
|
4
|
+
// pattern… whatever a ray-input takes), so one control says both what a
|
|
5
|
+
// value is and the value: "a file at this path", "text", "an amount in €".
|
|
6
|
+
//
|
|
7
|
+
// A ray-input underneath: label, description, error, sizes, forms, the
|
|
8
|
+
// left/right slots and validation work the same.
|
|
9
|
+
//
|
|
10
|
+
// <ray-mode-input mode="text">
|
|
11
|
+
// <option value="text" label="Text"></option>
|
|
12
|
+
// <option value="file" label="File" data-icon="file" data-set-placeholder="Path to a file"></option>
|
|
13
|
+
// </ray-mode-input>
|
|
14
|
+
//
|
|
15
|
+
// or from script: `el.options = [{ value, label, icon, hint, description,
|
|
16
|
+
// disabled, attrs: { placeholder: "…" } }]`.
|
|
17
|
+
//
|
|
18
|
+
// Choosing fires a cancelable ray-mode-change ({ mode, previous, option });
|
|
19
|
+
// cancelling it keeps the previous mode. Alt+ArrowDown in the field opens
|
|
20
|
+
// the menu. The menu is a ray-menu of radio items, native with native-menu.
|
|
21
|
+
|
|
22
|
+
import { css, define } from "../../core/core.js";
|
|
23
|
+
import { fieldTemplate } from "../../core/form.js";
|
|
24
|
+
import { RayInput } from "../input/input.js";
|
|
25
|
+
import "../menu/menu.js";
|
|
26
|
+
|
|
27
|
+
const styles = css`
|
|
28
|
+
.mode-menu { display: contents; }
|
|
29
|
+
.trigger {
|
|
30
|
+
display: inline-flex;
|
|
31
|
+
flex: none;
|
|
32
|
+
align-items: center;
|
|
33
|
+
gap: 0.25em;
|
|
34
|
+
height: max(1.5rem, calc(var(--_h) * 0.7));
|
|
35
|
+
min-width: max(1.5rem, calc(var(--_h) * 0.7));
|
|
36
|
+
margin-inline: calc(var(--_px) * 0.35);
|
|
37
|
+
padding: 0 0.35em;
|
|
38
|
+
font: inherit;
|
|
39
|
+
font-size: var(--_fs);
|
|
40
|
+
color: var(--ray-muted-foreground);
|
|
41
|
+
white-space: nowrap;
|
|
42
|
+
background: transparent;
|
|
43
|
+
border: 0;
|
|
44
|
+
border-radius: var(--ray-radius-sm);
|
|
45
|
+
cursor: pointer;
|
|
46
|
+
}
|
|
47
|
+
.trigger:hover, .trigger[aria-expanded="true"] { color: var(--ray-foreground); background: var(--ray-default-hover); }
|
|
48
|
+
.trigger:disabled { cursor: not-allowed; opacity: 0.6; }
|
|
49
|
+
.trigger [data-ray-icon] svg { width: 1.1em; height: 1.1em; }
|
|
50
|
+
.trigger .chevron svg { width: 0.85em; height: 0.85em; }
|
|
51
|
+
.trigger .text:empty, .trigger .icon:empty { display: none; }
|
|
52
|
+
/* At the start unless position="end". */
|
|
53
|
+
:host(:not([position="end"])) .mode-box { order: -1; }
|
|
54
|
+
.item-description { display: block; font-size: 0.85em; color: var(--ray-muted-foreground); }
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
// An option from an <option> child: label, data-icon, data-hint,
|
|
58
|
+
// data-description, disabled, and every data-set-<attr> as an attribute
|
|
59
|
+
// for the field.
|
|
60
|
+
function optionOf(node) {
|
|
61
|
+
const attrs = {};
|
|
62
|
+
for (const a of node.attributes) {
|
|
63
|
+
if (a.name.startsWith("data-set-")) attrs[a.name.slice(9)] = a.value;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
value: node.value,
|
|
67
|
+
label: node.getAttribute("label") ?? node.textContent.trim(),
|
|
68
|
+
icon: node.dataset.icon || "",
|
|
69
|
+
hint: node.dataset.hint || "",
|
|
70
|
+
description: node.dataset.description || "",
|
|
71
|
+
disabled: node.disabled,
|
|
72
|
+
attrs,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class RayModeInput extends RayInput {
|
|
77
|
+
static properties = {
|
|
78
|
+
...RayInput.properties,
|
|
79
|
+
mode: { type: String, reflect: true },
|
|
80
|
+
options: { attribute: false },
|
|
81
|
+
display: { type: String, reflect: true },
|
|
82
|
+
position: { type: String, reflect: true, default: "start" },
|
|
83
|
+
menuLabel: { type: String, default: "Mode" },
|
|
84
|
+
nativeMenu: { type: Boolean, reflect: true },
|
|
85
|
+
};
|
|
86
|
+
static styles = [...RayInput.styles, styles];
|
|
87
|
+
static template = fieldTemplate(
|
|
88
|
+
`<input id="control" class="control" part="input">` +
|
|
89
|
+
`<span class="mode-box"><ray-menu class="mode-menu" placement="bottom-start" part="menu">` +
|
|
90
|
+
`<button type="button" slot="trigger" class="trigger focus-ring" part="trigger">` +
|
|
91
|
+
`<span class="icon" part="trigger-icon"></span><span class="text" part="trigger-label"></span>` +
|
|
92
|
+
`<span class="chevron" data-ray-icon="ui.expand" aria-hidden="true"></span>` +
|
|
93
|
+
`</button></ray-menu></span>`,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// The field's own value of each attribute a mode changed, to give it back.
|
|
97
|
+
#saved = new Map();
|
|
98
|
+
#applied = null;
|
|
99
|
+
|
|
100
|
+
/** The modes in use: the `options` property, else the <option> children. */
|
|
101
|
+
get modes() {
|
|
102
|
+
if (Array.isArray(this.options)) return this.options.map((o) => ({ attrs: {}, ...o, value: String(o.value) }));
|
|
103
|
+
return [...this.querySelectorAll(":scope > option")].map(optionOf);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The chosen mode (the first when `mode` names none). */
|
|
107
|
+
get currentOption() {
|
|
108
|
+
const all = this.modes;
|
|
109
|
+
return all.find((o) => o.value === this.mode) ?? all[0] ?? null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
wire() {
|
|
113
|
+
super.wire();
|
|
114
|
+
const menu = this.$(".mode-menu");
|
|
115
|
+
// Keep the caret in the field while the pointer opens the menu.
|
|
116
|
+
this.$(".trigger").addEventListener("pointerdown", (e) => e.preventDefault());
|
|
117
|
+
menu.addEventListener("ray-select", (e) => {
|
|
118
|
+
e.stopPropagation();
|
|
119
|
+
this.choose(e.detail.value);
|
|
120
|
+
this.control.focus();
|
|
121
|
+
});
|
|
122
|
+
this.control.addEventListener("keydown", (e) => {
|
|
123
|
+
if (e.altKey && e.key === "ArrowDown") {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
if (menu.native) menu.popupNative();
|
|
126
|
+
else menu.open = true;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
// <option> children may come, go or change (the field's own attributes are not modes).
|
|
130
|
+
new MutationObserver((records) => {
|
|
131
|
+
if (records.some((r) => r.type === "childList" || r.target !== this)) this.requestUpdate();
|
|
132
|
+
}).observe(this, { childList: true, subtree: true, attributes: true, characterData: true });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Switches to the mode `value` as the user would: fires ray-mode-change, which may cancel it. */
|
|
136
|
+
choose(value) {
|
|
137
|
+
const option = this.modes.find((o) => o.value === value);
|
|
138
|
+
const previous = this.currentOption;
|
|
139
|
+
if (!option || option.disabled || option.value === previous?.value) {
|
|
140
|
+
this.requestUpdate();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
this.mode = value;
|
|
144
|
+
const go = this.emit("ray-mode-change", { mode: value, previous: previous?.value ?? null, option }, { cancelable: true });
|
|
145
|
+
if (!go) this.mode = previous?.value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The chosen mode's attributes on the field; the previous mode's taken back.
|
|
149
|
+
#apply(option) {
|
|
150
|
+
if (option && this.#applied === option.value) return;
|
|
151
|
+
for (const [name, value] of this.#saved) {
|
|
152
|
+
if (value === null) this.removeAttribute(name);
|
|
153
|
+
else this.setAttribute(name, value);
|
|
154
|
+
}
|
|
155
|
+
this.#saved.clear();
|
|
156
|
+
for (const [name, value] of Object.entries(option?.attrs ?? {})) {
|
|
157
|
+
this.#saved.set(name, this.getAttribute(name));
|
|
158
|
+
this.setAttribute(name, value);
|
|
159
|
+
}
|
|
160
|
+
this.#applied = option?.value ?? null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
update(changed) {
|
|
164
|
+
const option = this.currentOption;
|
|
165
|
+
// The setter, so the mode is reflected (CSS can read [mode="file"]).
|
|
166
|
+
if (option && this.mode !== option.value) this.mode = option.value;
|
|
167
|
+
this.#apply(option);
|
|
168
|
+
super.update(changed);
|
|
169
|
+
this.#renderMenu(option);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#renderMenu(option) {
|
|
173
|
+
const menu = this.$(".mode-menu");
|
|
174
|
+
const trigger = this.$(".trigger");
|
|
175
|
+
menu.native = Boolean(this.nativeMenu);
|
|
176
|
+
menu.placement = this.position === "end" ? "bottom-end" : "bottom-start";
|
|
177
|
+
const display = this.display || (option?.icon ? "icon" : "label");
|
|
178
|
+
trigger.querySelector(".text").textContent = display === "icon" || !option ? "" : option.hint || option.label;
|
|
179
|
+
const icon = trigger.querySelector(".icon");
|
|
180
|
+
const iconName = display === "label" || !option?.icon ? "" : option.icon;
|
|
181
|
+
if ((icon.dataset.rayIcon ?? "") !== iconName) {
|
|
182
|
+
icon.replaceChildren();
|
|
183
|
+
if (iconName) icon.dataset.rayIcon = iconName;
|
|
184
|
+
else delete icon.dataset.rayIcon;
|
|
185
|
+
}
|
|
186
|
+
// The trigger is named with the current mode: "Mode: File".
|
|
187
|
+
const name = option ? `${this.menuLabel ?? "Mode"}: ${option.label}` : this.menuLabel ?? "Mode";
|
|
188
|
+
trigger.setAttribute("aria-label", name);
|
|
189
|
+
trigger.title = name;
|
|
190
|
+
trigger.disabled = this.effectivelyDisabled || Boolean(this.readonly);
|
|
191
|
+
|
|
192
|
+
// The menu: one radio item per mode, the current one checked.
|
|
193
|
+
const all = this.modes;
|
|
194
|
+
// A native menu takes the item's whole text as its title: no description there.
|
|
195
|
+
const describe = !this.nativeMenu;
|
|
196
|
+
const key = JSON.stringify([describe, all.map((o) => [o.value, o.label, o.description, Boolean(o.disabled)])]);
|
|
197
|
+
if (menu.dataset.key !== key) {
|
|
198
|
+
menu.dataset.key = key;
|
|
199
|
+
for (const old of menu.querySelectorAll("ray-menu-item")) old.remove();
|
|
200
|
+
for (const o of all) {
|
|
201
|
+
const item = document.createElement("ray-menu-item");
|
|
202
|
+
item.type = "radio";
|
|
203
|
+
item.name = "mode";
|
|
204
|
+
item.value = o.value;
|
|
205
|
+
item.disabled = Boolean(o.disabled);
|
|
206
|
+
item.append(o.label);
|
|
207
|
+
if (o.description && describe) {
|
|
208
|
+
const d = document.createElement("span");
|
|
209
|
+
d.className = "item-description";
|
|
210
|
+
d.textContent = o.description;
|
|
211
|
+
item.append(d);
|
|
212
|
+
}
|
|
213
|
+
menu.append(item);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const item of menu.querySelectorAll("ray-menu-item")) item.checked = item.value === option?.value;
|
|
217
|
+
this.paintIcons(this.shadowRoot);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
define("ray-mode-input", RayModeInput);
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
// <ray-sidebar>: the side navigation of an app shell (shadcn's Sidebar).
|
|
2
2
|
// Header, content and footer slots; on wide screens a panel that can
|
|
3
|
-
// collapse to an icon rail
|
|
3
|
+
// collapse (`collapsed`) to an icon rail, or out of view with
|
|
4
|
+
// collapse="offcanvas"; below `breakpoint` an off-canvas
|
|
4
5
|
// drawer (`open`): a modal <dialog> sliding in from its side, with focus
|
|
5
6
|
// trapped, Escape and backdrop click closing it.
|
|
6
7
|
//
|
|
7
8
|
// toggle() does the right thing for the screen: collapse/expand on wide
|
|
8
9
|
// screens, open/close the drawer on narrow ones — wire it to a menu button.
|
|
9
10
|
//
|
|
11
|
+
// With `resizable`, its inner edge is a separator (WAI-ARIA window splitter)
|
|
12
|
+
// that drags or arrow-keys the width between min-width and max-width;
|
|
13
|
+
// `autosave="key"` keeps it (localStorage).
|
|
14
|
+
//
|
|
10
15
|
// <ray-sidebar-group label="Projects">: a titled section of nav links; the
|
|
11
16
|
// title hides when the sidebar collapses.
|
|
12
17
|
|
|
13
18
|
import { RayElement, css, define } from "../../core/core.js";
|
|
14
19
|
import { overlayStyles } from "../../core/styles.js";
|
|
20
|
+
import { isRtl } from "../../core/direction.js";
|
|
15
21
|
import { play, playAll } from "../../core/motion.js";
|
|
16
22
|
|
|
17
23
|
const styles = css`
|
|
@@ -26,6 +32,10 @@ const styles = css`
|
|
|
26
32
|
transition: width var(--ray-duration-base) var(--ray-easing-standard);
|
|
27
33
|
}
|
|
28
34
|
:host([collapsed]:not([data-mobile])) { width: var(--_rail); }
|
|
35
|
+
/* offcanvas: collapsed is out of view, and out of the tab order once the
|
|
36
|
+
width has animated to 0 (visibility waits for it; showing does not). */
|
|
37
|
+
:host([collapse="offcanvas"][collapsed]:not([data-mobile])) { width: 0; padding: 0; }
|
|
38
|
+
:host([collapse="offcanvas"][collapsed]:not([data-mobile])) .panel { visibility: hidden; transition: visibility 0s var(--ray-duration-base); }
|
|
29
39
|
:host([data-mobile]) { width: 0; }
|
|
30
40
|
.panel {
|
|
31
41
|
box-sizing: border-box;
|
|
@@ -76,11 +86,35 @@ const styles = css`
|
|
|
76
86
|
@keyframes ray-sidebar-in { from { transform: translateX(var(--_from)); } }
|
|
77
87
|
dialog::backdrop { animation: ray-fade-in var(--ray-duration-base) var(--ray-easing-out); }
|
|
78
88
|
@keyframes ray-fade-in { from { opacity: 0; } }
|
|
89
|
+
|
|
90
|
+
/* resizable: a separator on the edge that faces the content. */
|
|
91
|
+
:host { position: relative; }
|
|
92
|
+
.resizer { display: none; position: absolute; z-index: 1; inset-block: 0; inset-inline-end: -0.25rem; width: 0.5rem; cursor: col-resize; touch-action: none; outline: none; }
|
|
93
|
+
:host([resizable]:not([collapsed]):not([data-mobile])) .resizer { display: block; }
|
|
94
|
+
:host([side="end"]) .resizer { inset-inline-end: auto; inset-inline-start: -0.25rem; }
|
|
95
|
+
:host([side="left"]) .resizer { inset-inline: auto; right: -0.25rem; } /* physical */
|
|
96
|
+
:host([side="right"]) .resizer { inset-inline: auto; left: -0.25rem; } /* physical */
|
|
97
|
+
.resizer::before {
|
|
98
|
+
content: "";
|
|
99
|
+
position: absolute;
|
|
100
|
+
inset-block: 0;
|
|
101
|
+
left: 50%; /* physical */
|
|
102
|
+
width: 3px;
|
|
103
|
+
background: transparent;
|
|
104
|
+
transform: translateX(-50%);
|
|
105
|
+
transition: background-color var(--ray-duration-fast) var(--ray-easing-standard);
|
|
106
|
+
}
|
|
107
|
+
.resizer:hover::before, .resizer:focus-visible::before, :host([data-dragging]) .resizer::before { background: var(--ray-primary); }
|
|
108
|
+
:host([data-dragging]) { transition: none; user-select: none; cursor: col-resize; }
|
|
79
109
|
`;
|
|
80
110
|
|
|
111
|
+
const STEP = 10;
|
|
112
|
+
const BIG_STEP = 50;
|
|
113
|
+
|
|
81
114
|
export class RaySidebar extends RayElement {
|
|
82
115
|
static properties = {
|
|
83
116
|
collapsed: { type: Boolean, reflect: true },
|
|
117
|
+
collapse: { type: String, reflect: true, default: "icon" },
|
|
84
118
|
open: { type: Boolean, reflect: true },
|
|
85
119
|
side: { type: String, reflect: true, default: "start" },
|
|
86
120
|
variant: { type: String, reflect: true, default: "sidebar" },
|
|
@@ -88,6 +122,11 @@ export class RaySidebar extends RayElement {
|
|
|
88
122
|
collapsedWidth: { type: String },
|
|
89
123
|
breakpoint: { type: String, default: "48em" },
|
|
90
124
|
label: { type: String, default: "Sidebar" },
|
|
125
|
+
resizable: { type: Boolean, reflect: true },
|
|
126
|
+
minWidth: { type: Number, default: 192 },
|
|
127
|
+
maxWidth: { type: Number, default: 480 },
|
|
128
|
+
autosave: { type: String },
|
|
129
|
+
resizeLabel: { type: String, default: "Resize sidebar" },
|
|
91
130
|
};
|
|
92
131
|
static styles = [overlayStyles, styles];
|
|
93
132
|
static template = `
|
|
@@ -96,7 +135,8 @@ export class RaySidebar extends RayElement {
|
|
|
96
135
|
<nav class="content" part="content"><slot></slot></nav>
|
|
97
136
|
<div class="footer" part="footer"><slot name="footer"></slot></div>
|
|
98
137
|
</div>
|
|
99
|
-
<dialog part="drawer"></dialog
|
|
138
|
+
<dialog part="drawer"></dialog>
|
|
139
|
+
<div class="resizer" part="resizer" role="separator" tabindex="0" aria-orientation="vertical"></div>`;
|
|
100
140
|
|
|
101
141
|
#mq = null;
|
|
102
142
|
#exit = null;
|
|
@@ -128,11 +168,27 @@ export class RaySidebar extends RayElement {
|
|
|
128
168
|
});
|
|
129
169
|
// New nav links and groups pick up the collapsed state.
|
|
130
170
|
this.$(".content slot").addEventListener("slotchange", () => this._syncCollapsed());
|
|
171
|
+
this._px = null;
|
|
172
|
+
this.resizer = this.$(".resizer");
|
|
173
|
+
this.resizer.addEventListener("pointerdown", (e) => this._down(e));
|
|
174
|
+
this.resizer.addEventListener("keydown", (e) => this._resizeKey(e));
|
|
131
175
|
}
|
|
132
176
|
|
|
133
177
|
connectedCallback() {
|
|
134
178
|
super.connectedCallback();
|
|
135
179
|
this._watch();
|
|
180
|
+
if (this.autosave && !this._restored) {
|
|
181
|
+
this._restored = true;
|
|
182
|
+
try {
|
|
183
|
+
const saved = Number(localStorage.getItem(`ray-sidebar:${this.autosave}`));
|
|
184
|
+
if (saved > 0) {
|
|
185
|
+
this._px = saved;
|
|
186
|
+
this.requestUpdate();
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// No storage: the default width is fine.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
136
192
|
}
|
|
137
193
|
|
|
138
194
|
disconnectedCallback() {
|
|
@@ -176,13 +232,21 @@ export class RaySidebar extends RayElement {
|
|
|
176
232
|
}
|
|
177
233
|
|
|
178
234
|
update(changed) {
|
|
179
|
-
|
|
235
|
+
// A later change of `width` wins over a dragged or restored one.
|
|
236
|
+
if (changed.has("width") && this._updated) this._px = null;
|
|
237
|
+
this._updated = true;
|
|
238
|
+
if (this._px !== null) this.style.setProperty("--_w", `${this._clamp(this._px)}px`);
|
|
239
|
+
else if (this.width) this.style.setProperty("--_w", this.width);
|
|
180
240
|
else this.style.removeProperty("--_w");
|
|
241
|
+
this.resizer.setAttribute("aria-label", this.resizeLabel ?? "Resize sidebar");
|
|
242
|
+
this.resizer.setAttribute("aria-valuemin", String(this.minWidth ?? 0));
|
|
243
|
+
this.resizer.setAttribute("aria-valuemax", String(this.maxWidth ?? 0));
|
|
244
|
+
queueMicrotask(() => this.resizer.setAttribute("aria-valuenow", String(Math.round(this._width()))));
|
|
181
245
|
if (this.collapsedWidth) this.style.setProperty("--_rail", this.collapsedWidth);
|
|
182
246
|
else this.style.removeProperty("--_rail");
|
|
183
247
|
this.$(".content").setAttribute("aria-label", this.label ?? "Sidebar");
|
|
184
248
|
if (changed.has("breakpoint") && this.isConnected) this._watch();
|
|
185
|
-
if (changed.has("collapsed")) this._syncCollapsed();
|
|
249
|
+
if (changed.has("collapsed") || changed.has("collapse")) this._syncCollapsed();
|
|
186
250
|
if (changed.has("open") && this.mobile) {
|
|
187
251
|
if (this.open && this.#exit) {
|
|
188
252
|
this.#exit.cancel();
|
|
@@ -195,6 +259,77 @@ export class RaySidebar extends RayElement {
|
|
|
195
259
|
}
|
|
196
260
|
}
|
|
197
261
|
|
|
262
|
+
/** The expanded width in px (not mid-transition when it was set here). */
|
|
263
|
+
_width() {
|
|
264
|
+
return this._px !== null ? this._clamp(this._px) : this.getBoundingClientRect().width;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
_clamp(px) {
|
|
268
|
+
return Math.round(Math.max(this.minWidth ?? 0, Math.min(px, this.maxWidth ?? Infinity)));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The screen side it sits on: start and end follow the writing direction. */
|
|
272
|
+
_onLeft() {
|
|
273
|
+
if (this.side === "left" || this.side === "right") return this.side === "left";
|
|
274
|
+
return (this.side === "end") === isRtl(this);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
_resizeTo(px, commit) {
|
|
278
|
+
this._px = this._clamp(px);
|
|
279
|
+
this.requestUpdate();
|
|
280
|
+
if (!commit) return;
|
|
281
|
+
if (this.autosave) {
|
|
282
|
+
try {
|
|
283
|
+
localStorage.setItem(`ray-sidebar:${this.autosave}`, String(this._px));
|
|
284
|
+
} catch {
|
|
285
|
+
// Not persisted: fine.
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
this.emit("ray-resize", { width: this._px });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
_down(e) {
|
|
292
|
+
if (e.button !== 0) return;
|
|
293
|
+
e.preventDefault();
|
|
294
|
+
this.resizer.focus();
|
|
295
|
+
try {
|
|
296
|
+
this.resizer.setPointerCapture(e.pointerId);
|
|
297
|
+
} catch {
|
|
298
|
+
// The pointer is already gone: drag without capture.
|
|
299
|
+
}
|
|
300
|
+
this.setAttribute("data-dragging", "");
|
|
301
|
+
const rect = this.getBoundingClientRect();
|
|
302
|
+
const left = this._onLeft();
|
|
303
|
+
const at = (ev) => (left ? ev.clientX - rect.left : rect.right - ev.clientX);
|
|
304
|
+
const move = (ev) => this._resizeTo(at(ev), false);
|
|
305
|
+
const up = (ev) => {
|
|
306
|
+
this.resizer.removeEventListener("pointermove", move);
|
|
307
|
+
this.resizer.removeEventListener("pointerup", up);
|
|
308
|
+
this.resizer.removeEventListener("pointercancel", up);
|
|
309
|
+
this.removeAttribute("data-dragging");
|
|
310
|
+
this._resizeTo(at(ev), true);
|
|
311
|
+
};
|
|
312
|
+
this.resizer.addEventListener("pointermove", move);
|
|
313
|
+
this.resizer.addEventListener("pointerup", up);
|
|
314
|
+
this.resizer.addEventListener("pointercancel", up);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Arrows move the edge on screen: toward the content widens it.
|
|
318
|
+
_resizeKey(e) {
|
|
319
|
+
const step = e.shiftKey ? BIG_STEP : STEP;
|
|
320
|
+
const current = this._width();
|
|
321
|
+
const wider = this._onLeft() ? "ArrowRight" : "ArrowLeft";
|
|
322
|
+
const narrower = this._onLeft() ? "ArrowLeft" : "ArrowRight";
|
|
323
|
+
let next = null;
|
|
324
|
+
if (e.key === wider) next = current + step;
|
|
325
|
+
else if (e.key === narrower) next = current - step;
|
|
326
|
+
else if (e.key === "Home") next = this.minWidth ?? 0;
|
|
327
|
+
else if (e.key === "End") next = this.maxWidth ?? current;
|
|
328
|
+
if (next === null) return;
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
this._resizeTo(next, true);
|
|
331
|
+
}
|
|
332
|
+
|
|
198
333
|
_show() {
|
|
199
334
|
this.dialog.showModal();
|
|
200
335
|
this.emit("ray-open");
|
|
@@ -217,7 +352,7 @@ export class RaySidebar extends RayElement {
|
|
|
217
352
|
|
|
218
353
|
// The rail shows icons only: nav links and group titles follow.
|
|
219
354
|
_syncCollapsed() {
|
|
220
|
-
const rail = Boolean(this.collapsed) && !this.mobile;
|
|
355
|
+
const rail = Boolean(this.collapsed) && !this.mobile && this.collapse !== "offcanvas";
|
|
221
356
|
for (const el of this.querySelectorAll("ray-nav-link")) el.iconOnly = rail;
|
|
222
357
|
for (const el of this.querySelectorAll("ray-sidebar-group")) el.toggleAttribute("collapsed", rail);
|
|
223
358
|
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
//
|
|
5
5
|
// The divider is a focusable separator (WAI-ARIA window splitter): arrows
|
|
6
6
|
// move it (Shift: faster), Home/End go to min/max, Enter collapses the start
|
|
7
|
-
// pane. With `autosave="key"` the size
|
|
7
|
+
// pane. With `autosave="key"` the size and the collapsed state survive
|
|
8
|
+
// reloads (localStorage).
|
|
8
9
|
|
|
9
10
|
import { RayElement, css, define } from "../../core/core.js";
|
|
10
11
|
import { isRtl, inlineKey } from "../../core/direction.js";
|
|
@@ -88,6 +89,9 @@ export class RaySplit extends RayElement {
|
|
|
88
89
|
try {
|
|
89
90
|
const saved = Number(localStorage.getItem(`ray-split:${this.autosave}`));
|
|
90
91
|
if (saved > 0) this._px = saved;
|
|
92
|
+
// "1" or "0"; nothing saved keeps the markup's collapsed.
|
|
93
|
+
const collapsed = localStorage.getItem(`ray-split:${this.autosave}:collapsed`);
|
|
94
|
+
if (collapsed !== null) this.collapsed = collapsed === "1";
|
|
91
95
|
} catch {
|
|
92
96
|
// No storage: the default position is fine.
|
|
93
97
|
}
|
|
@@ -109,6 +113,14 @@ export class RaySplit extends RayElement {
|
|
|
109
113
|
// A later change of `position` wins over a dragged/restored size; the
|
|
110
114
|
// first update (position from the markup) does not.
|
|
111
115
|
if (changed.has("position") && this._updated) this._px = null;
|
|
116
|
+
// Collapsed by the divider or by script after the first update: saved.
|
|
117
|
+
if (changed.has("collapsed") && this._updated && this.autosave) {
|
|
118
|
+
try {
|
|
119
|
+
localStorage.setItem(`ray-split:${this.autosave}:collapsed`, this.collapsed ? "1" : "0");
|
|
120
|
+
} catch {
|
|
121
|
+
// Not persisted: fine.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
112
124
|
this._updated = true;
|
|
113
125
|
const min = Math.max(0, this.min ?? 0);
|
|
114
126
|
const max = this.max ?? null;
|
package/components/tabs/tabs.js
CHANGED
|
@@ -3,13 +3,25 @@
|
|
|
3
3
|
// Tabs and panels are light-DOM children, so their roles, ids,
|
|
4
4
|
// aria-controls and aria-labelledby work as plain attributes. A tab and its
|
|
5
5
|
// panel share a `value`.
|
|
6
|
+
//
|
|
7
|
+
// Too many tabs for the width: the list scrolls sideways, fades the edge that
|
|
8
|
+
// hides tabs (the host carries data-overflow-start / data-overflow-end), turns
|
|
9
|
+
// a vertical wheel into a sideways scroll and keeps the selected tab in view.
|
|
6
10
|
|
|
7
11
|
import { RayElement, css, define, uid } from "../../core/core.js";
|
|
8
12
|
import { inlineKey } from "../../core/direction.js";
|
|
13
|
+
import { SideScroller, sideScrollerStyles } from "../../core/scroll.js";
|
|
9
14
|
import { applyColor } from "../../core/styles.js";
|
|
10
15
|
|
|
11
16
|
const tabsStyles = css`
|
|
12
17
|
:host { display: block; }
|
|
18
|
+
/* The size scale, inherited by the ray-tab children (own names: they reach
|
|
19
|
+
the panels' content too). */
|
|
20
|
+
:host { --_tab-h: var(--ray-control-height-sm); --_tab-fs: var(--ray-font-size-sm); --_tab-px: var(--ray-space-4); --_tab-gap: var(--ray-space-2); }
|
|
21
|
+
:host([size="xs"]) { --_tab-h: var(--ray-control-height-xs); --_tab-fs: var(--ray-font-size-xs); --_tab-px: var(--ray-space-3); --_tab-gap: var(--ray-space-1); }
|
|
22
|
+
:host([size="md"]) { --_tab-h: var(--ray-control-height-md); --_tab-fs: var(--ray-font-size-md); --_tab-px: var(--ray-space-5); }
|
|
23
|
+
:host([size="lg"]) { --_tab-h: var(--ray-control-height-lg); --_tab-fs: var(--ray-font-size-lg); --_tab-px: var(--ray-space-6); --_tab-gap: var(--ray-space-3); }
|
|
24
|
+
:host([size="xl"]) { --_tab-h: var(--ray-control-height-xl); --_tab-fs: var(--ray-font-size-xl); --_tab-px: var(--ray-space-8); --_tab-gap: var(--ray-space-3); }
|
|
13
25
|
:host([orientation="vertical"]) { display: flex; gap: var(--ray-space-4); }
|
|
14
26
|
/* Too many tabs for the width: the list scrolls sideways instead of
|
|
15
27
|
wrapping (a wrapped underline reads as two separate tab bars). The
|
|
@@ -42,8 +54,9 @@ export class RayTabs extends RayElement {
|
|
|
42
54
|
color: { type: String, reflect: true },
|
|
43
55
|
activation: { type: String, default: "auto" },
|
|
44
56
|
grow: { type: Boolean, reflect: true },
|
|
57
|
+
size: { type: String, reflect: true, default: "sm" },
|
|
45
58
|
};
|
|
46
|
-
static styles = [tabsStyles];
|
|
59
|
+
static styles = [sideScrollerStyles, tabsStyles];
|
|
47
60
|
static template = `<div class="list" part="list" role="tablist"><slot name="tab"></slot></div><div class="panels" part="panels"><slot></slot></div>`;
|
|
48
61
|
|
|
49
62
|
constructor() {
|
|
@@ -56,6 +69,20 @@ export class RayTabs extends RayElement {
|
|
|
56
69
|
for (const slot of this.shadowRoot.querySelectorAll("slot")) {
|
|
57
70
|
slot.addEventListener("slotchange", () => this.requestUpdate());
|
|
58
71
|
}
|
|
72
|
+
this._scroller = new SideScroller(this, this.$(".list"), {
|
|
73
|
+
active: () => this.orientation !== "vertical",
|
|
74
|
+
onResize: () => this.requestUpdate(),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
connectedCallback() {
|
|
79
|
+
super.connectedCallback();
|
|
80
|
+
this._scroller.connect();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
disconnectedCallback() {
|
|
84
|
+
super.disconnectedCallback();
|
|
85
|
+
this._scroller.disconnect();
|
|
59
86
|
}
|
|
60
87
|
|
|
61
88
|
get tabs() {
|
|
@@ -99,6 +126,11 @@ export class RayTabs extends RayElement {
|
|
|
99
126
|
panel.tabIndex = 0;
|
|
100
127
|
panel.hidden = panel.value !== this.value;
|
|
101
128
|
}
|
|
129
|
+
// Once laid out: a list with no width yet tries again on the next update.
|
|
130
|
+
if (this.value !== this._revealed && this._scroller.reveal(tabs.find((t) => t.value === this.value))) {
|
|
131
|
+
this._revealed = this.value;
|
|
132
|
+
}
|
|
133
|
+
this._scroller.update();
|
|
102
134
|
}
|
|
103
135
|
|
|
104
136
|
_select(tab, user) {
|
|
@@ -139,10 +171,10 @@ const tabStyles = css`
|
|
|
139
171
|
display: inline-flex;
|
|
140
172
|
align-items: center;
|
|
141
173
|
justify-content: center;
|
|
142
|
-
gap: var(--ray-space-2);
|
|
143
|
-
min-height: var(--ray-control-height-sm);
|
|
144
|
-
padding: 0 var(--ray-space-4);
|
|
145
|
-
font-size: var(--ray-font-size-sm);
|
|
174
|
+
gap: var(--_tab-gap, var(--ray-space-2));
|
|
175
|
+
min-height: var(--_tab-h, var(--ray-control-height-sm));
|
|
176
|
+
padding: 0 var(--_tab-px, var(--ray-space-4));
|
|
177
|
+
font-size: var(--_tab-fs, var(--ray-font-size-sm));
|
|
146
178
|
font-weight: var(--ray-font-weight-medium);
|
|
147
179
|
color: var(--ray-muted-foreground);
|
|
148
180
|
white-space: nowrap;
|
|
@@ -177,15 +209,20 @@ const tabStyles = css`
|
|
|
177
209
|
.section { display: inline-flex; }
|
|
178
210
|
.section:not([data-filled]) { display: none; }
|
|
179
211
|
::slotted(svg) { width: 1rem; height: 1rem; }
|
|
212
|
+
/* count: a small number after the label, lighter than a badge. */
|
|
213
|
+
.count { font-size: 0.85em; font-variant-numeric: tabular-nums; color: var(--ray-muted-foreground); }
|
|
214
|
+
.count:empty { display: none; }
|
|
215
|
+
:host([data-active]) .count, :host(:hover) .count { color: inherit; opacity: 0.75; }
|
|
180
216
|
`;
|
|
181
217
|
|
|
182
218
|
export class RayTab extends RayElement {
|
|
183
219
|
static properties = {
|
|
184
220
|
value: { type: String, reflect: true },
|
|
185
221
|
disabled: { type: Boolean, reflect: true },
|
|
222
|
+
count: { type: String, reflect: true },
|
|
186
223
|
};
|
|
187
224
|
static styles = [tabStyles];
|
|
188
|
-
static template = `<span class="section" part="left-section"><slot name="left"></slot></span><slot></slot><span class="section" part="right-section"><slot name="right"></slot></span>`;
|
|
225
|
+
static template = `<span class="section" part="left-section"><slot name="left"></slot></span><slot></slot><span class="count" part="count"></span><span class="section" part="right-section"><slot name="right"></slot></span>`;
|
|
189
226
|
|
|
190
227
|
constructor() {
|
|
191
228
|
super();
|
|
@@ -201,6 +238,7 @@ export class RayTab extends RayElement {
|
|
|
201
238
|
}
|
|
202
239
|
|
|
203
240
|
update(changed) {
|
|
241
|
+
this.$(".count").textContent = this.count ?? "";
|
|
204
242
|
if (changed.has("value") || changed.has("disabled")) this.closest("ray-tabs")?.requestUpdate();
|
|
205
243
|
}
|
|
206
244
|
}
|
|
@@ -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() {
|