@raylang/elements 0.8.0 → 0.10.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.
@@ -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 (`collapsed`); below `breakpoint` an off-canvas
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
- if (this.width) this.style.setProperty("--_w", this.width);
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 survives reloads (localStorage).
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;
@@ -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() {