@raylang/elements 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +12 -2
- package/base.css +111 -0
- package/components/accordion/accordion.js +197 -0
- package/components/alert/alert.js +112 -0
- package/components/app-bar/app-bar.js +168 -0
- package/components/app-shell/app-shell.js +198 -0
- package/components/aspect-ratio/aspect-ratio.js +59 -0
- package/components/attachment/attachment.js +333 -0
- package/components/avatar/avatar.js +96 -0
- package/components/badge/badge.js +63 -0
- package/components/blockquote/blockquote.js +128 -0
- package/components/bottom-nav/bottom-nav.js +309 -0
- package/components/breadcrumbs/breadcrumbs.js +175 -0
- package/components/button/button.js +283 -0
- package/components/button-group/button-group.js +72 -0
- package/components/card/card.js +73 -0
- package/components/carousel/carousel.js +435 -0
- package/components/center/center.js +26 -0
- package/components/checkbox/checkbox.js +152 -0
- package/components/checkbox-group/checkbox-group.js +152 -0
- package/components/code/code.js +574 -0
- package/components/color-picker/color-picker.js +775 -0
- package/components/combobox/combobox.js +563 -0
- package/components/command-palette/command-palette.js +510 -0
- package/components/container/container.js +41 -0
- package/components/date-picker/date-picker.js +594 -0
- package/components/dialog/dialog.js +329 -0
- package/components/drawer/drawer.js +139 -0
- package/components/empty-state/empty-state.js +66 -0
- package/components/file-upload/file-upload.js +589 -0
- package/components/grid/grid.js +96 -0
- package/components/group/group.js +40 -0
- package/components/icon/icon.js +58 -0
- package/components/icon-button/icon-button.js +30 -0
- package/components/indicator/indicator.js +112 -0
- package/components/input/input.js +85 -0
- package/components/kbd/kbd.js +60 -0
- package/components/menu/menu.js +296 -0
- package/components/nav-link/nav-link.js +159 -0
- package/components/notification/notification.js +591 -0
- package/components/number-input/number-input.js +241 -0
- package/components/pagination/pagination.js +194 -0
- package/components/password-input/password-input.js +85 -0
- package/components/path-input/path-input.js +137 -0
- package/components/popover/popover.js +85 -0
- package/components/progress/progress.js +109 -0
- package/components/radio/radio.js +253 -0
- package/components/scroll-area/scroll-area.js +158 -0
- package/components/search/search.js +157 -0
- package/components/segmented-control/segmented-control.js +190 -0
- package/components/select/select.js +506 -0
- package/components/separator/separator.js +58 -0
- package/components/sidebar/sidebar.js +252 -0
- package/components/skeleton/skeleton.js +90 -0
- package/components/slider/slider.js +155 -0
- package/components/spinner/spinner.js +39 -0
- package/components/split/split.js +221 -0
- package/components/stack/stack.js +46 -0
- package/components/stat/stat.js +79 -0
- package/components/statusbar/statusbar.js +110 -0
- package/components/stepper/stepper.js +239 -0
- package/components/switch/switch.js +45 -0
- package/components/table/table.js +328 -0
- package/components/tabs/tabs.js +224 -0
- package/components/textarea/textarea.js +105 -0
- package/components/theme-toggle/theme-toggle.js +78 -0
- package/components/timeline/timeline.js +150 -0
- package/components/toggle/toggle.js +130 -0
- package/components/toggle-group/toggle-group.js +216 -0
- package/components/tooltip/tooltip.js +101 -0
- package/core/charts.js +107 -0
- package/core/color.js +110 -0
- package/core/core.js +556 -0
- package/core/dismiss.js +69 -0
- package/core/floating.js +195 -0
- package/core/focus.js +70 -0
- package/core/form.js +235 -0
- package/core/host.js +200 -0
- package/core/hotkey.js +40 -0
- package/core/icons.js +104 -0
- package/core/motion.js +73 -0
- package/core/position.js +91 -0
- package/core/raylang.js +167 -0
- package/core/scroll.js +58 -0
- package/core/styles.js +211 -0
- package/core.js +16 -0
- package/custom-elements.json +18545 -0
- package/index.js +74 -0
- package/package.json +312 -4
- package/ray-elements.d.ts +2570 -0
- package/tokens.css +796 -0
- package/tokens.json +2083 -0
- package/vscode.html-custom-data.json +5499 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// <ray-number-input>: a number field (Mantine's NumberInput): + and −
|
|
2
|
+
// buttons (hold to repeat), the arrow keys (Shift: ×10), min/max/step,
|
|
3
|
+
// a fixed number of decimals, a prefix and a suffix ("$", " kg") and a
|
|
4
|
+
// thousands separator in the shown text. The value is a number (or empty);
|
|
5
|
+
// the form gets its plain digits.
|
|
6
|
+
//
|
|
7
|
+
// An ARIA spinbutton: aria-valuenow/min/max/valuetext on the text field.
|
|
8
|
+
|
|
9
|
+
import { css, define } from "../../core/core.js";
|
|
10
|
+
import { sizeStyles, radiusStyles } from "../../core/styles.js";
|
|
11
|
+
import { FieldControl, fieldStyles, boxStyles, fieldTemplate } from "../../core/form.js";
|
|
12
|
+
|
|
13
|
+
const styles = css`
|
|
14
|
+
.control { font-variant-numeric: tabular-nums; }
|
|
15
|
+
.steppers {
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
align-self: stretch;
|
|
19
|
+
flex: none;
|
|
20
|
+
width: calc(var(--_h) * 0.75);
|
|
21
|
+
border-inline-start: 1px solid var(--ray-input);
|
|
22
|
+
}
|
|
23
|
+
:host([hide-controls]) .steppers { display: none; }
|
|
24
|
+
:host([variant="filled"]) .steppers, :host([variant="unstyled"]) .steppers { border-color: transparent; }
|
|
25
|
+
.step {
|
|
26
|
+
display: grid;
|
|
27
|
+
place-items: center;
|
|
28
|
+
flex: 1;
|
|
29
|
+
margin: 0;
|
|
30
|
+
padding: 0;
|
|
31
|
+
color: var(--ray-muted-foreground);
|
|
32
|
+
background: transparent;
|
|
33
|
+
border: 0;
|
|
34
|
+
cursor: pointer;
|
|
35
|
+
--ray-icon-size: 0.8em;
|
|
36
|
+
}
|
|
37
|
+
.step + .step { border-top: 1px solid var(--ray-input); }
|
|
38
|
+
:host([variant="filled"]) .step + .step, :host([variant="unstyled"]) .step + .step { border-color: transparent; }
|
|
39
|
+
.step:hover:not(:disabled) { color: var(--ray-foreground); background: var(--ray-default-hover); }
|
|
40
|
+
.step:disabled { cursor: not-allowed; opacity: 0.4; }
|
|
41
|
+
.up { border-start-end-radius: calc(var(--_r) - 1px); }
|
|
42
|
+
.down { border-end-end-radius: calc(var(--_r) - 1px); }
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
/** A number from text with a prefix/suffix and separators; NaN if none. */
|
|
46
|
+
export function parseNumber(text, { decimal = ".", thousands = "" } = {}) {
|
|
47
|
+
let s = String(text ?? "");
|
|
48
|
+
if (thousands) s = s.split(thousands).join("");
|
|
49
|
+
if (decimal !== ".") s = s.split(decimal).join(".");
|
|
50
|
+
const m = s.match(/-?\d*\.?\d+(?:e[+-]?\d+)?|-?\d+\.?/i);
|
|
51
|
+
return m ? Number(m[0]) : NaN;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A number as text with `decimals`, a thousands separator and a decimal mark. */
|
|
55
|
+
export function formatNumber(n, { decimals, thousands = "", decimal = "." } = {}) {
|
|
56
|
+
if (!Number.isFinite(n)) return "";
|
|
57
|
+
let s = decimals !== undefined && decimals !== null ? n.toFixed(decimals) : String(n);
|
|
58
|
+
let [int, frac] = s.split(".");
|
|
59
|
+
if (thousands) int = int.replace(/\B(?=(\d{3})+(?!\d))/g, thousands);
|
|
60
|
+
return frac !== undefined ? `${int}${decimal}${frac}` : int;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class RayNumberInput extends FieldControl {
|
|
64
|
+
static properties = {
|
|
65
|
+
...FieldControl.fieldProperties,
|
|
66
|
+
value: { type: Number },
|
|
67
|
+
min: { type: Number },
|
|
68
|
+
max: { type: Number },
|
|
69
|
+
step: { type: Number, default: 1 },
|
|
70
|
+
decimals: { type: Number },
|
|
71
|
+
// Shadows Element.prefix (the namespace prefix): null when unset, like it.
|
|
72
|
+
prefix: { type: String, default: null },
|
|
73
|
+
suffix: { type: String },
|
|
74
|
+
thousandsSeparator: { type: String, attribute: "thousands-separator" },
|
|
75
|
+
decimalSeparator: { type: String, attribute: "decimal-separator", default: "." },
|
|
76
|
+
placeholder: { type: String },
|
|
77
|
+
hideControls: { type: Boolean, reflect: true, attribute: "hide-controls" },
|
|
78
|
+
clamp: { type: String, default: "blur" },
|
|
79
|
+
readonly: { type: Boolean, reflect: true },
|
|
80
|
+
};
|
|
81
|
+
static styles = [sizeStyles, radiusStyles, fieldStyles, boxStyles, styles];
|
|
82
|
+
static template = fieldTemplate(
|
|
83
|
+
`<input id="control" class="control" part="input" role="spinbutton" inputmode="decimal" autocomplete="off" spellcheck="false">` +
|
|
84
|
+
`<span class="steppers" part="controls"><button type="button" class="step up" part="increment" tabindex="-1" aria-label="Increment"><span data-ray-icon="chevron-up"></span></button><button type="button" class="step down" part="decrement" tabindex="-1" aria-label="Decrement"><span data-ray-icon="chevron-down"></span></button></span>`,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
#typing = false;
|
|
88
|
+
#repeat = 0;
|
|
89
|
+
|
|
90
|
+
wire() {
|
|
91
|
+
this._default = this.getAttribute("value") !== null ? Number(this.getAttribute("value")) : undefined;
|
|
92
|
+
const c = this.control;
|
|
93
|
+
c.addEventListener("input", () => {
|
|
94
|
+
this.#typing = true;
|
|
95
|
+
const n = this._parse(c.value);
|
|
96
|
+
this.commitProperty("value", Number.isFinite(n) ? (this.clamp === "strict" ? this._clamp(n) : n) : undefined);
|
|
97
|
+
this._commit();
|
|
98
|
+
this._aria();
|
|
99
|
+
});
|
|
100
|
+
c.addEventListener("blur", () => {
|
|
101
|
+
this.#typing = false;
|
|
102
|
+
if (this.value !== undefined && this.clamp !== "none") this._set(this._clamp(this.value), false);
|
|
103
|
+
this._paint();
|
|
104
|
+
});
|
|
105
|
+
c.addEventListener("change", (e) => this.forward(e));
|
|
106
|
+
c.addEventListener("keydown", (e) => this._key(e));
|
|
107
|
+
// + and −: a press steps once; held, it repeats faster and faster.
|
|
108
|
+
for (const [sel, dir] of [[".up", 1], [".down", -1]]) {
|
|
109
|
+
const b = this.$(sel);
|
|
110
|
+
b.addEventListener("pointerdown", (e) => {
|
|
111
|
+
if (b.disabled || e.button !== 0) return;
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
c.focus();
|
|
114
|
+
this.stepBy(dir);
|
|
115
|
+
let delay = 400;
|
|
116
|
+
const again = () => {
|
|
117
|
+
this.stepBy(dir);
|
|
118
|
+
delay = Math.max(40, delay * 0.8);
|
|
119
|
+
this.#repeat = setTimeout(again, delay);
|
|
120
|
+
};
|
|
121
|
+
this.#repeat = setTimeout(again, delay);
|
|
122
|
+
});
|
|
123
|
+
for (const ev of ["pointerup", "pointerleave", "pointercancel"]) b.addEventListener(ev, () => clearTimeout(this.#repeat));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
disconnectedCallback() {
|
|
128
|
+
super.disconnectedCallback();
|
|
129
|
+
clearTimeout(this.#repeat);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
update() {
|
|
133
|
+
const c = this.control;
|
|
134
|
+
c.readOnly = Boolean(this.readonly);
|
|
135
|
+
if (this.placeholder !== undefined) c.placeholder = this.placeholder;
|
|
136
|
+
if (!this.#typing) this._paint();
|
|
137
|
+
this.syncField();
|
|
138
|
+
this._aria();
|
|
139
|
+
this._commit();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Adds `dir` steps (negative: down) from the current value, clamped; fires input and change. */
|
|
143
|
+
stepBy(dir) {
|
|
144
|
+
if (this.effectivelyDisabled || this.readonly) return;
|
|
145
|
+
const step = this.step || 1;
|
|
146
|
+
const base = this.value ?? (dir > 0 ? (this.min ?? 0) - step : (this.max ?? 0) + step);
|
|
147
|
+
// Round to the step's precision (0.1 + 0.2 stays 0.3).
|
|
148
|
+
const places = Math.max(this._places(step), this._places(base), this.decimals ?? 0);
|
|
149
|
+
const next = Number((base + dir * step).toFixed(places));
|
|
150
|
+
this._set(this._clamp(next), true);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
_places(n) {
|
|
154
|
+
const s = String(n);
|
|
155
|
+
return s.includes(".") ? s.split(".")[1].length : 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_clamp(n) {
|
|
159
|
+
let v = n;
|
|
160
|
+
if (this.min !== undefined && this.min !== null && v < this.min) v = this.min;
|
|
161
|
+
if (this.max !== undefined && this.max !== null && v > this.max) v = this.max;
|
|
162
|
+
return this.decimals !== undefined && this.decimals !== null ? Number(v.toFixed(this.decimals)) : v;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_parse(text) {
|
|
166
|
+
let s = String(text ?? "");
|
|
167
|
+
if (this.prefix) s = s.split(this.prefix).join("");
|
|
168
|
+
if (this.suffix) s = s.split(this.suffix).join("");
|
|
169
|
+
return parseNumber(s, { decimal: this.decimalSeparator || ".", thousands: this.thousandsSeparator || "" });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_set(n, user) {
|
|
173
|
+
const changed = n !== this.value;
|
|
174
|
+
this.commitProperty("value", n);
|
|
175
|
+
this._paint();
|
|
176
|
+
this._commit();
|
|
177
|
+
this._aria();
|
|
178
|
+
if (user && changed) {
|
|
179
|
+
this.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
|
|
180
|
+
this.dispatchEvent(new Event("change", { bubbles: true }));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The shown text: prefix, separators, decimals, suffix.
|
|
185
|
+
_text() {
|
|
186
|
+
if (this.value === undefined || this.value === null || !Number.isFinite(this.value)) return "";
|
|
187
|
+
const num = formatNumber(this.value, { decimals: this.decimals, thousands: this.thousandsSeparator || "", decimal: this.decimalSeparator || "." });
|
|
188
|
+
return `${this.prefix ?? ""}${num}${this.suffix ?? ""}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
_paint() {
|
|
192
|
+
const t = this._text();
|
|
193
|
+
if (this.control.value !== t) this.control.value = t;
|
|
194
|
+
const v = this.value;
|
|
195
|
+
const off = this.effectivelyDisabled || this.readonly;
|
|
196
|
+
this.$(".up").disabled = off || (v !== undefined && this.max !== undefined && v >= this.max);
|
|
197
|
+
this.$(".down").disabled = off || (v !== undefined && this.min !== undefined && v <= this.min);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
_aria() {
|
|
201
|
+
const c = this.control;
|
|
202
|
+
const set = (a, v) => (v === undefined || v === null || Number.isNaN(v) ? c.removeAttribute(a) : c.setAttribute(a, String(v)));
|
|
203
|
+
set("aria-valuenow", this.value);
|
|
204
|
+
set("aria-valuemin", this.min);
|
|
205
|
+
set("aria-valuemax", this.max);
|
|
206
|
+
if (this.prefix || this.suffix || this.thousandsSeparator) set("aria-valuetext", this._text() || undefined);
|
|
207
|
+
else c.removeAttribute("aria-valuetext");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
_key(e) {
|
|
211
|
+
const big = e.shiftKey ? 10 : 1;
|
|
212
|
+
if (e.key === "ArrowUp") { e.preventDefault(); this.stepBy(big); }
|
|
213
|
+
else if (e.key === "ArrowDown") { e.preventDefault(); this.stepBy(-big); }
|
|
214
|
+
else if (e.key === "PageUp") { e.preventDefault(); this.stepBy(10); }
|
|
215
|
+
else if (e.key === "PageDown") { e.preventDefault(); this.stepBy(-10); }
|
|
216
|
+
else if (e.key === "Home" && this.min !== undefined) { e.preventDefault(); this._set(this.min, true); }
|
|
217
|
+
else if (e.key === "End" && this.max !== undefined) { e.preventDefault(); this._set(this.max, true); }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
_commit() {
|
|
221
|
+
this.internals.setFormValue(this.value === undefined || this.value === null || !Number.isFinite(this.value) ? null : String(this.value));
|
|
222
|
+
const c = this.control;
|
|
223
|
+
let msg = "";
|
|
224
|
+
if (this.required && (this.value === undefined || this.value === null)) msg = "Please enter a number.";
|
|
225
|
+
else if (this.value !== undefined && this.min !== undefined && this.value < this.min) msg = `The minimum is ${this.min}.`;
|
|
226
|
+
else if (this.value !== undefined && this.max !== undefined && this.value > this.max) msg = `The maximum is ${this.max}.`;
|
|
227
|
+
if (msg) this.internals.setValidity(this.value === undefined ? { valueMissing: true } : this.value < (this.min ?? -Infinity) ? { rangeUnderflow: true } : { rangeOverflow: true }, msg, c);
|
|
228
|
+
else this.internals.setValidity({});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
formResetCallback() {
|
|
232
|
+
this.value = this._default;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
formStateRestoreCallback(state) {
|
|
236
|
+
const n = Number(state);
|
|
237
|
+
this.value = state === null || state === "" || !Number.isFinite(n) ? undefined : n;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
define("ray-number-input", RayNumberInput);
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// <ray-pagination>: page navigation (Mantine's Pagination). Previous/next,
|
|
2
|
+
// optional first/last, page numbers with ellipses. The number of items stays
|
|
3
|
+
// the same while moving through the pages, so the control does not jump.
|
|
4
|
+
|
|
5
|
+
import { RayElement, css, define } from "../../core/core.js";
|
|
6
|
+
import { applyColor, sizeStyles, radiusStyles, variantStyles } from "../../core/styles.js";
|
|
7
|
+
|
|
8
|
+
const styles = css`
|
|
9
|
+
:host { display: block; }
|
|
10
|
+
:host([hidden]) { display: none; }
|
|
11
|
+
.list {
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-wrap: wrap;
|
|
14
|
+
align-items: center;
|
|
15
|
+
gap: var(--ray-space-1);
|
|
16
|
+
margin: 0;
|
|
17
|
+
padding: 0;
|
|
18
|
+
list-style: none;
|
|
19
|
+
}
|
|
20
|
+
.item {
|
|
21
|
+
display: inline-flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
justify-content: center;
|
|
24
|
+
min-width: var(--_h);
|
|
25
|
+
height: var(--_h);
|
|
26
|
+
padding: 0 calc(var(--_px) * 0.35);
|
|
27
|
+
font: inherit;
|
|
28
|
+
font-size: var(--_fs);
|
|
29
|
+
font-weight: var(--ray-font-weight-medium);
|
|
30
|
+
font-variant-numeric: tabular-nums;
|
|
31
|
+
line-height: 1;
|
|
32
|
+
border-radius: var(--_r);
|
|
33
|
+
cursor: pointer;
|
|
34
|
+
user-select: none;
|
|
35
|
+
-webkit-tap-highlight-color: transparent;
|
|
36
|
+
}
|
|
37
|
+
.item:not([data-disabled]):active { transform: translateY(1px); }
|
|
38
|
+
/* Disabled: the current page keeps its fill, dimmed. */
|
|
39
|
+
.item[aria-current="page"]:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
40
|
+
.item svg { width: 1em; height: 1em; }
|
|
41
|
+
.dots {
|
|
42
|
+
display: inline-flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
justify-content: center;
|
|
45
|
+
min-width: var(--_h);
|
|
46
|
+
height: var(--_h);
|
|
47
|
+
color: var(--ray-muted-foreground);
|
|
48
|
+
font-size: var(--_fs);
|
|
49
|
+
}
|
|
50
|
+
.dots svg { width: 1em; height: 1em; }
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The items to show: page numbers and "dots", Mantine's algorithm. Always
|
|
55
|
+
* siblings * 2 + 3 + boundaries * 2 items when there are more pages than that.
|
|
56
|
+
* @returns {(number | "dots")[]}
|
|
57
|
+
*/
|
|
58
|
+
export function paginationRange(total, value, siblings = 1, boundaries = 1) {
|
|
59
|
+
total = Math.max(0, Math.floor(total || 0));
|
|
60
|
+
if (total === 0) return [];
|
|
61
|
+
const active = Math.min(Math.max(1, Math.floor(value || 1)), total);
|
|
62
|
+
const range = (a, b) => (b < a ? [] : Array.from({ length: b - a + 1 }, (_, i) => a + i));
|
|
63
|
+
const count = siblings * 2 + 3 + boundaries * 2;
|
|
64
|
+
if (count >= total) return range(1, total);
|
|
65
|
+
const left = Math.max(active - siblings, boundaries);
|
|
66
|
+
const right = Math.min(active + siblings, total - boundaries);
|
|
67
|
+
const leftDots = left > boundaries + 2;
|
|
68
|
+
const rightDots = right < total - (boundaries + 1);
|
|
69
|
+
if (!leftDots && rightDots) {
|
|
70
|
+
const n = siblings * 2 + boundaries + 2;
|
|
71
|
+
return [...range(1, n), "dots", ...range(total - boundaries + 1, total)];
|
|
72
|
+
}
|
|
73
|
+
if (leftDots && !rightDots) {
|
|
74
|
+
const n = boundaries + 1 + siblings * 2;
|
|
75
|
+
return [...range(1, boundaries), "dots", ...range(total - n, total)];
|
|
76
|
+
}
|
|
77
|
+
return [...range(1, boundaries), "dots", ...range(left, right), "dots", ...range(total - boundaries + 1, total)];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class RayPagination extends RayElement {
|
|
81
|
+
static properties = {
|
|
82
|
+
total: { type: Number, default: 1 },
|
|
83
|
+
value: { type: Number, default: 1 },
|
|
84
|
+
siblings: { type: Number, default: 1 },
|
|
85
|
+
boundaries: { type: Number, default: 1 },
|
|
86
|
+
size: { type: String, reflect: true, default: "sm" },
|
|
87
|
+
radius: { type: String, reflect: true },
|
|
88
|
+
color: { type: String, reflect: true },
|
|
89
|
+
disabled: { type: Boolean, reflect: true },
|
|
90
|
+
withEdges: { type: Boolean, reflect: true },
|
|
91
|
+
hideControls: { type: Boolean, reflect: true },
|
|
92
|
+
label: { type: String, default: "Pagination" },
|
|
93
|
+
};
|
|
94
|
+
static styles = [sizeStyles, radiusStyles, variantStyles, styles];
|
|
95
|
+
static template = `<nav part="root"><ul class="list" part="list"></ul></nav>`;
|
|
96
|
+
|
|
97
|
+
constructor() {
|
|
98
|
+
super();
|
|
99
|
+
this.$(".list").addEventListener("click", (e) => {
|
|
100
|
+
const b = e.target.closest?.("button[data-page]");
|
|
101
|
+
if (!b || b.disabled) return;
|
|
102
|
+
this._go(Number(b.dataset.page));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The current page, clamped to 1…total. */
|
|
107
|
+
get page() {
|
|
108
|
+
const total = Math.max(1, this.total || 1);
|
|
109
|
+
return Math.min(Math.max(1, this.value || 1), total);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Goes to a page (clamped), firing change when it changes. */
|
|
113
|
+
setPage(page) {
|
|
114
|
+
this._go(page);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
next() { this._go(this.page + 1); }
|
|
118
|
+
previous() { this._go(this.page - 1); }
|
|
119
|
+
first() { this._go(1); }
|
|
120
|
+
last() { this._go(Math.max(1, this.total || 1)); }
|
|
121
|
+
|
|
122
|
+
_go(page) {
|
|
123
|
+
const total = Math.max(1, this.total || 1);
|
|
124
|
+
const p = Math.min(Math.max(1, page), total);
|
|
125
|
+
if (p === this.page || this.disabled) return;
|
|
126
|
+
this.commitProperty("value", p);
|
|
127
|
+
this.requestUpdate();
|
|
128
|
+
this.dispatchEvent(new Event("change", { bubbles: true }));
|
|
129
|
+
// Keep the focus on the activated page (the list is rebuilt).
|
|
130
|
+
queueMicrotask(() => this.$(`button[data-page="${p}"][data-number]`)?.focus());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
update() {
|
|
134
|
+
this.$("nav").setAttribute("aria-label", this.label || "Pagination");
|
|
135
|
+
const total = Math.max(0, this.total || 0);
|
|
136
|
+
const page = this.page;
|
|
137
|
+
const list = this.$(".list");
|
|
138
|
+
const focused = this.shadowRoot.activeElement?.dataset?.role;
|
|
139
|
+
const items = [];
|
|
140
|
+
const control = (role, target, icon, label, off) => ({ kind: "control", role, target, icon, label, off });
|
|
141
|
+
if (!this.hideControls) {
|
|
142
|
+
if (this.withEdges) items.push(control("first", 1, "chevrons-left", "First page", page <= 1));
|
|
143
|
+
items.push(control("previous", page - 1, "chevron-left", "Previous page", page <= 1));
|
|
144
|
+
}
|
|
145
|
+
for (const it of paginationRange(total, page, this.siblings ?? 1, this.boundaries ?? 1)) {
|
|
146
|
+
items.push(it === "dots" ? { kind: "dots" } : { kind: "page", target: it });
|
|
147
|
+
}
|
|
148
|
+
if (!this.hideControls) {
|
|
149
|
+
items.push(control("next", page + 1, "chevron-right", "Next page", page >= total));
|
|
150
|
+
if (this.withEdges) items.push(control("last", total, "chevrons-right", "Last page", page >= total));
|
|
151
|
+
}
|
|
152
|
+
list.replaceChildren(...items.map((it) => this._item(it, page)));
|
|
153
|
+
this.paintIcons(list);
|
|
154
|
+
// Prev/next keep the focus across re-renders (e.g. holding Enter).
|
|
155
|
+
if (focused) this.$(`button[data-role="${focused}"]`)?.focus();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_item(it, page) {
|
|
159
|
+
const li = document.createElement("li");
|
|
160
|
+
if (it.kind === "dots") {
|
|
161
|
+
li.innerHTML = `<span class="dots" part="dots" aria-hidden="true">…</span>`;
|
|
162
|
+
return li;
|
|
163
|
+
}
|
|
164
|
+
const b = document.createElement("button");
|
|
165
|
+
b.type = "button";
|
|
166
|
+
b.className = "item focus-ring";
|
|
167
|
+
b.dataset.page = String(it.target);
|
|
168
|
+
applyColor(b, this.color);
|
|
169
|
+
if (it.kind === "page") {
|
|
170
|
+
const active = it.target === page;
|
|
171
|
+
b.dataset.number = "";
|
|
172
|
+
b.setAttribute("part", active ? "item active" : "item");
|
|
173
|
+
b.dataset.variant = active ? "filled" : "default";
|
|
174
|
+
b.textContent = String(it.target);
|
|
175
|
+
b.setAttribute("aria-label", `Page ${it.target}`);
|
|
176
|
+
if (active) b.setAttribute("aria-current", "page");
|
|
177
|
+
b.disabled = Boolean(this.disabled);
|
|
178
|
+
if (!(active && b.disabled)) b.setAttribute("data-interactive", "");
|
|
179
|
+
} else {
|
|
180
|
+
b.dataset.role = it.role;
|
|
181
|
+
b.setAttribute("part", `item control ${it.role}`);
|
|
182
|
+
b.dataset.variant = "default";
|
|
183
|
+
b.setAttribute("aria-label", it.label);
|
|
184
|
+
b.innerHTML = `<span data-ray-icon="${it.icon}" aria-hidden="true"></span>`;
|
|
185
|
+
b.disabled = Boolean(this.disabled || it.off);
|
|
186
|
+
b.setAttribute("data-interactive", "");
|
|
187
|
+
}
|
|
188
|
+
b.toggleAttribute("data-disabled", b.disabled);
|
|
189
|
+
li.append(b);
|
|
190
|
+
return li;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
define("ray-pagination", RayPagination);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// <ray-password-input>: a password field with a button that shows or hides
|
|
2
|
+
// what was typed (Mantine's PasswordInput). A ray-input underneath: label,
|
|
3
|
+
// description, error, sizes, validation and forms work the same.
|
|
4
|
+
//
|
|
5
|
+
// The toggle keeps the caret and the focus in the field, says what it does
|
|
6
|
+
// (aria-label, aria-pressed) and fires ray-visibility-change.
|
|
7
|
+
|
|
8
|
+
import { css, define } from "../../core/core.js";
|
|
9
|
+
import { fieldTemplate } from "../../core/form.js";
|
|
10
|
+
import { RayInput } from "../input/input.js";
|
|
11
|
+
|
|
12
|
+
const styles = css`
|
|
13
|
+
.toggle {
|
|
14
|
+
display: inline-flex;
|
|
15
|
+
flex: none;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: center;
|
|
18
|
+
width: max(1.5rem, calc(var(--_h) * 0.7));
|
|
19
|
+
height: max(1.5rem, calc(var(--_h) * 0.7));
|
|
20
|
+
margin-inline-end: calc(var(--_px) * 0.35);
|
|
21
|
+
padding: 0;
|
|
22
|
+
color: var(--ray-muted-foreground);
|
|
23
|
+
background: transparent;
|
|
24
|
+
border: 0;
|
|
25
|
+
border-radius: var(--ray-radius-sm);
|
|
26
|
+
cursor: pointer;
|
|
27
|
+
}
|
|
28
|
+
.toggle:hover { color: var(--ray-foreground); background: var(--ray-default-hover); }
|
|
29
|
+
.toggle:disabled { cursor: not-allowed; opacity: 0.6; }
|
|
30
|
+
.toggle svg { width: 1.1em; height: 1.1em; }
|
|
31
|
+
/* Dots read better a little apart; typed text keeps the normal spacing. */
|
|
32
|
+
.control[type="password"]:not(:placeholder-shown) { letter-spacing: 0.08em; }
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
export class RayPasswordInput extends RayInput {
|
|
36
|
+
static properties = {
|
|
37
|
+
...RayInput.properties,
|
|
38
|
+
type: { type: String, reflect: false, default: "password" },
|
|
39
|
+
visible: { type: Boolean, reflect: true },
|
|
40
|
+
showLabel: { type: String, default: "Show password" },
|
|
41
|
+
hideLabel: { type: String, default: "Hide password" },
|
|
42
|
+
};
|
|
43
|
+
static styles = [...RayInput.styles, styles];
|
|
44
|
+
static template = fieldTemplate(
|
|
45
|
+
`<input id="control" class="control" part="input" type="password" autocomplete="current-password" spellcheck="false" autocapitalize="off">` +
|
|
46
|
+
`<button type="button" class="toggle focus-ring" part="toggle" aria-pressed="false"><span data-ray-icon="eye" aria-hidden="true"></span></button>`,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
wire() {
|
|
50
|
+
super.wire();
|
|
51
|
+
const toggle = this.$(".toggle");
|
|
52
|
+
// Keep the focus (and the caret) in the field when the pointer toggles.
|
|
53
|
+
toggle.addEventListener("pointerdown", (e) => e.preventDefault());
|
|
54
|
+
toggle.addEventListener("click", () => {
|
|
55
|
+
this.visible = !this.visible;
|
|
56
|
+
this.emit("ray-visibility-change", { visible: this.visible });
|
|
57
|
+
if (this.shadowRoot.activeElement !== toggle) this.control.focus();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
update(changed) {
|
|
62
|
+
// The type follows `visible` (a type set from outside is ignored).
|
|
63
|
+
const want = this.visible ? "text" : "password";
|
|
64
|
+
if (this.type !== want) this.commitProperty("type", want);
|
|
65
|
+
super.update(changed);
|
|
66
|
+
const toggle = this.$(".toggle");
|
|
67
|
+
toggle.setAttribute("aria-pressed", String(Boolean(this.visible)));
|
|
68
|
+
toggle.setAttribute("aria-label", this.visible ? this.hideLabel : this.showLabel);
|
|
69
|
+
toggle.title = toggle.getAttribute("aria-label");
|
|
70
|
+
toggle.disabled = this.effectivelyDisabled;
|
|
71
|
+
const icon = toggle.firstElementChild;
|
|
72
|
+
const name = this.visible ? "eye-off" : "eye";
|
|
73
|
+
if (icon.dataset.rayIcon !== name) {
|
|
74
|
+
icon.dataset.rayIcon = name;
|
|
75
|
+
this.paintIcons(toggle);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
formResetCallback() {
|
|
80
|
+
super.formResetCallback();
|
|
81
|
+
this.visible = false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
define("ray-password-input", RayPasswordInput);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// <ray-path-input>: a field for a path on the user's disk: a file to open, a
|
|
2
|
+
// folder, or where to save. A ray-input underneath: label, description,
|
|
3
|
+
// error, sizes, validation and forms work the same, and the path can always
|
|
4
|
+
// be typed or pasted.
|
|
5
|
+
//
|
|
6
|
+
// With a native host that offers "pick_path" (core/host.js; a std/ui window
|
|
7
|
+
// with core/raylang.js), a Browse… button opens the system dialog, starting
|
|
8
|
+
// where the current path points; the choice fills the field and fires input
|
|
9
|
+
// and change. Without one (a browser), it is a plain text field: a page
|
|
10
|
+
// cannot read paths from a browser's file picker.
|
|
11
|
+
|
|
12
|
+
import { css, define } from "../../core/core.js";
|
|
13
|
+
import { fieldTemplate } from "../../core/form.js";
|
|
14
|
+
import { getHost, hostCan, onHostChange, acceptFilters } from "../../core/host.js";
|
|
15
|
+
import { RayInput } from "../input/input.js";
|
|
16
|
+
|
|
17
|
+
const styles = css`
|
|
18
|
+
.control { font-variant-ligatures: none; }
|
|
19
|
+
.browse {
|
|
20
|
+
display: inline-flex;
|
|
21
|
+
flex: none;
|
|
22
|
+
align-items: center;
|
|
23
|
+
gap: 0.35em;
|
|
24
|
+
height: calc(var(--_h) - 0.5rem);
|
|
25
|
+
margin-inline-end: 0.25rem;
|
|
26
|
+
padding: 0 0.6em;
|
|
27
|
+
font: inherit;
|
|
28
|
+
font-size: 0.9em;
|
|
29
|
+
font-weight: 500;
|
|
30
|
+
color: var(--ray-foreground);
|
|
31
|
+
white-space: nowrap;
|
|
32
|
+
background: var(--ray-default-hover);
|
|
33
|
+
border: 0;
|
|
34
|
+
border-radius: var(--ray-radius-sm);
|
|
35
|
+
cursor: pointer;
|
|
36
|
+
}
|
|
37
|
+
.browse:hover { background: color-mix(in srgb, var(--ray-foreground) 12%, var(--ray-default-hover)); }
|
|
38
|
+
.browse:disabled { cursor: not-allowed; opacity: 0.6; }
|
|
39
|
+
.browse[hidden] { display: none; }
|
|
40
|
+
.browse svg { width: 1.05em; height: 1.05em; }
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
const ICONS = { open: "file", folder: "folder", save: "file" };
|
|
44
|
+
|
|
45
|
+
/** The folder part of a path ("" when it has none), with / or \ separators. */
|
|
46
|
+
export function dirName(path) {
|
|
47
|
+
const p = String(path ?? "");
|
|
48
|
+
const cut = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
|
|
49
|
+
return cut <= 0 ? (cut === 0 ? p.slice(0, 1) : "") : p.slice(0, cut);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The last part of a path. */
|
|
53
|
+
export function baseName(path) {
|
|
54
|
+
return String(path ?? "").split(/[\\/]/).pop();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class RayPathInput extends RayInput {
|
|
58
|
+
static properties = {
|
|
59
|
+
...RayInput.properties,
|
|
60
|
+
mode: { type: String, reflect: true, default: "open" },
|
|
61
|
+
accept: { type: String, reflect: true },
|
|
62
|
+
browseLabel: { type: String, attribute: "browse-label", default: "Browse…" },
|
|
63
|
+
dialogTitle: { type: String, attribute: "dialog-title" },
|
|
64
|
+
};
|
|
65
|
+
static styles = [...RayInput.styles, styles];
|
|
66
|
+
static template = fieldTemplate(
|
|
67
|
+
`<input id="control" class="control" part="input" spellcheck="false" autocapitalize="off" autocomplete="off">` +
|
|
68
|
+
`<button type="button" class="browse focus-ring" part="browse" hidden><span data-ray-icon="file" aria-hidden="true"></span><span class="browse-text"></span></button>`,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
#stop = null;
|
|
72
|
+
|
|
73
|
+
wire() {
|
|
74
|
+
super.wire();
|
|
75
|
+
this.$(".browse").addEventListener("click", () => this.browse());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
connectedCallback() {
|
|
79
|
+
super.connectedCallback();
|
|
80
|
+
this.#stop ??= onHostChange(() => this.requestUpdate());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
disconnectedCallback() {
|
|
84
|
+
super.disconnectedCallback();
|
|
85
|
+
this.#stop?.();
|
|
86
|
+
this.#stop = null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether Browse… is available (a native host offers "pick_path"). */
|
|
90
|
+
get canBrowse() {
|
|
91
|
+
return hostCan("pick_path");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Opens the system dialog for `mode`; resolves to the chosen path, or null
|
|
96
|
+
* when the user cancels or there is no native host.
|
|
97
|
+
*/
|
|
98
|
+
async browse() {
|
|
99
|
+
if (this.effectivelyDisabled || this.readonly || !this.canBrowse) return null;
|
|
100
|
+
const current = this.value ?? "";
|
|
101
|
+
let path;
|
|
102
|
+
try {
|
|
103
|
+
path = await getHost().pickPath({
|
|
104
|
+
mode: this.mode || "open",
|
|
105
|
+
title: this.dialogTitle ?? this.label ?? "",
|
|
106
|
+
directory: this.mode === "folder" ? current : dirName(current),
|
|
107
|
+
suggested: this.mode === "save" ? baseName(current) : "",
|
|
108
|
+
filters: this.mode === "folder" ? [] : acceptFilters(this.accept),
|
|
109
|
+
});
|
|
110
|
+
} catch (e) {
|
|
111
|
+
this.emit("ray-error", { error: e });
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (path === null || path === undefined) return null;
|
|
115
|
+
this.value = path;
|
|
116
|
+
this.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
|
|
117
|
+
this.dispatchEvent(new Event("change", { bubbles: true }));
|
|
118
|
+
this.control.focus();
|
|
119
|
+
return path;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
update(changed) {
|
|
123
|
+
super.update(changed);
|
|
124
|
+
const btn = this.$(".browse");
|
|
125
|
+
btn.hidden = !this.canBrowse;
|
|
126
|
+
btn.disabled = this.effectivelyDisabled || Boolean(this.readonly);
|
|
127
|
+
this.$(".browse-text").textContent = this.browseLabel;
|
|
128
|
+
const icon = btn.firstElementChild;
|
|
129
|
+
const name = ICONS[this.mode] ?? "file";
|
|
130
|
+
if (icon.dataset.rayIcon !== name || !icon.firstChild) {
|
|
131
|
+
icon.dataset.rayIcon = name;
|
|
132
|
+
this.paintIcons(btn);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
define("ray-path-input", RayPathInput);
|