stimeo-ui 0.2.0 → 0.2.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -0
- data/dist/controllers/alert_dialog_controller.js +318 -0
- data/dist/controllers/carousel_controller.js +272 -0
- data/dist/controllers/clipboard_controller.js +144 -0
- data/dist/controllers/collapsible_controller.js +327 -0
- data/dist/controllers/color_picker_controller.js +213 -0
- data/dist/controllers/count_up_controller.js +8 -1
- data/dist/controllers/currency_input_controller.js +147 -0
- data/dist/controllers/data_grid_controller.js +168 -0
- data/dist/controllers/date_range_picker_controller.js +417 -0
- data/dist/controllers/dismissible_controller.js +117 -0
- data/dist/controllers/drawer_controller.js +630 -0
- data/dist/controllers/editable_controller.js +168 -0
- data/dist/controllers/file_dropzone_controller.js +165 -0
- data/dist/controllers/filter_controller.js +86 -0
- data/dist/controllers/flash_controller.js +36 -5
- data/dist/controllers/highlight_controller.js +6 -4
- data/dist/controllers/intersection_controller.js +41 -18
- data/dist/controllers/lazy_frame_controller.js +33 -11
- data/dist/controllers/masonry_controller.js +142 -0
- data/dist/controllers/menubar_controller.js +433 -0
- data/dist/controllers/multi_select_controller.js +472 -0
- data/dist/controllers/navigation_menu_controller.js +384 -0
- data/dist/controllers/overflow_indicator_controller.js +178 -27
- data/dist/controllers/password_reveal_controller.js +117 -0
- data/dist/controllers/range_slider_controller.js +166 -0
- data/dist/controllers/read_more_controller.js +194 -0
- data/dist/controllers/scroll_area_controller.js +15 -2
- data/dist/controllers/scroll_restore_controller.js +93 -0
- data/dist/controllers/scroll_visibility_controller.js +8 -4
- data/dist/controllers/scrollspy_controller.js +33 -11
- data/dist/controllers/separator_controller.js +87 -0
- data/dist/controllers/sidebar_controller.js +761 -0
- data/dist/controllers/stepper_controller.js +28 -12
- data/dist/controllers/stick_to_bottom_controller.js +8 -4
- data/dist/controllers/sticky_observer_controller.js +88 -20
- data/dist/controllers/tags_input_controller.js +275 -0
- data/dist/controllers/theme_controller.js +20 -10
- data/dist/controllers/time_picker_controller.js +212 -0
- data/dist/controllers/toast_controller.js +36 -9
- data/dist/controllers/transition_controller.js +153 -38
- data/dist/controllers/tree_view_controller.js +275 -0
- data/dist/index.js +811 -295
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +28 -2
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/count_up_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/reduced_motion.ts
|
|
6
|
+
function prefersReducedMotion() {
|
|
7
|
+
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
// src/controllers/count_up_controller.ts
|
|
4
11
|
var CountUpController = class extends Controller {
|
|
5
12
|
static values = {
|
|
@@ -32,7 +39,7 @@ var CountUpController = class extends Controller {
|
|
|
32
39
|
this.#finalText = this.element.textContent ?? "";
|
|
33
40
|
const target = Number.parseInt(this.#finalText.replace(/[^0-9-]/g, ""), 10);
|
|
34
41
|
if (Number.isNaN(target)) return;
|
|
35
|
-
if (
|
|
42
|
+
if (prefersReducedMotion()) {
|
|
36
43
|
this.element.setAttribute("data-count-up-done", "true");
|
|
37
44
|
this.dispatch("end", { detail: { value: target } });
|
|
38
45
|
return;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/currency_input_controller.ts
|
|
4
|
+
var CurrencyInputController = class extends Controller {
|
|
5
|
+
static targets = ["display", "field", "srValue"];
|
|
6
|
+
static values = {
|
|
7
|
+
locale: { type: String, default: "en-US" },
|
|
8
|
+
currency: { type: String, default: "" },
|
|
9
|
+
precision: { type: Number, default: 2 }
|
|
10
|
+
};
|
|
11
|
+
static actions = ["format", "onInput"];
|
|
12
|
+
static events = ["change"];
|
|
13
|
+
/** Last committed numeric value, to suppress duplicate `change` dispatches. */
|
|
14
|
+
#lastValue = null;
|
|
15
|
+
/** Normalizes any pre-filled display value to its fixed-precision form. */
|
|
16
|
+
connect() {
|
|
17
|
+
if (!this.hasDisplayTarget) return;
|
|
18
|
+
const parsed = this.#parse(this.displayTarget.value);
|
|
19
|
+
this.#lastValue = parsed === null ? null : round(parsed, this.precisionValue);
|
|
20
|
+
if (this.displayTarget.value.trim() !== "") {
|
|
21
|
+
this.#reformat(true);
|
|
22
|
+
} else {
|
|
23
|
+
this.#reflect(null, "");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Re-groups digits as the user types, preserving the caret position. */
|
|
27
|
+
onInput() {
|
|
28
|
+
this.#reformat(false);
|
|
29
|
+
}
|
|
30
|
+
/** Applies the fixed-precision rounding on blur. */
|
|
31
|
+
format() {
|
|
32
|
+
this.#reformat(true);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Parses the display value, rewrites it grouped (optionally at fixed
|
|
36
|
+
* precision), keeps the caret stable by digit count, and syncs the field,
|
|
37
|
+
* the screen-reader span, and the `change` event.
|
|
38
|
+
*/
|
|
39
|
+
#reformat(fixedPrecision) {
|
|
40
|
+
if (!this.hasDisplayTarget) return;
|
|
41
|
+
const raw = this.displayTarget.value;
|
|
42
|
+
const number = this.#parse(raw);
|
|
43
|
+
if (number === null) {
|
|
44
|
+
this.displayTarget.value = "";
|
|
45
|
+
this.#reflect(null, "");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const value = fixedPrecision ? round(number, this.precisionValue) : number;
|
|
49
|
+
const caret = this.displayTarget.selectionStart;
|
|
50
|
+
const digitsBeforeCaret = typeof caret === "number" ? countDigits(raw.slice(0, caret)) : null;
|
|
51
|
+
const formatted = this.#formatNumber(value, fixedPrecision);
|
|
52
|
+
this.displayTarget.value = formatted;
|
|
53
|
+
if (digitsBeforeCaret !== null) this.#restoreCaret(formatted, digitsBeforeCaret);
|
|
54
|
+
this.#reflect(value, formatted);
|
|
55
|
+
}
|
|
56
|
+
/** Restores the caret to sit just after the n-th digit of the new string. */
|
|
57
|
+
#restoreCaret(formatted, digitsBefore) {
|
|
58
|
+
let seen = 0;
|
|
59
|
+
let position = formatted.length;
|
|
60
|
+
for (let i = 0; i < formatted.length; i++) {
|
|
61
|
+
if (seen >= digitsBefore) {
|
|
62
|
+
position = i;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
if (/\d/.test(formatted[i])) seen += 1;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
this.displayTarget.setSelectionRange(position, position);
|
|
69
|
+
} catch {
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Writes the normalized value to the field, the SR span, and `change`. */
|
|
73
|
+
#reflect(value, formatted) {
|
|
74
|
+
const isEmpty = value === null;
|
|
75
|
+
if (this.hasFieldTarget) this.fieldTarget.value = isEmpty ? "" : String(value);
|
|
76
|
+
if (this.hasSrValueTarget) {
|
|
77
|
+
this.srValueTarget.textContent = isEmpty ? "" : this.#accessibleText(value);
|
|
78
|
+
}
|
|
79
|
+
this.element.toggleAttribute("data-stimeo--currency-input-empty", isEmpty);
|
|
80
|
+
if (value !== this.#lastValue) {
|
|
81
|
+
this.#lastValue = value;
|
|
82
|
+
if (!isEmpty) this.dispatch("change", { detail: { value, formatted } });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Parses arbitrary input text into a finite number, or `null` when blank. */
|
|
86
|
+
#parse(text) {
|
|
87
|
+
if (text.trim() === "") return null;
|
|
88
|
+
const { decimal } = this.#separators();
|
|
89
|
+
let cleaned = "";
|
|
90
|
+
let sawDot = false;
|
|
91
|
+
for (let i = 0; i < text.length; i++) {
|
|
92
|
+
const ch = text[i];
|
|
93
|
+
if (ch >= "0" && ch <= "9") cleaned += ch;
|
|
94
|
+
else if ((ch === "-" || ch === "+") && cleaned === "") cleaned += ch;
|
|
95
|
+
else if ((ch === decimal || ch === ".") && !sawDot) {
|
|
96
|
+
cleaned += ".";
|
|
97
|
+
sawDot = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (cleaned === "" || cleaned === "-" || cleaned === "+" || cleaned === ".") return null;
|
|
101
|
+
const value = Number(cleaned);
|
|
102
|
+
return Number.isFinite(value) ? value : null;
|
|
103
|
+
}
|
|
104
|
+
/** Formats a number with grouping for the display field. */
|
|
105
|
+
#formatNumber(value, fixedPrecision) {
|
|
106
|
+
const formatter = new Intl.NumberFormat(this.localeValue, {
|
|
107
|
+
useGrouping: true,
|
|
108
|
+
minimumFractionDigits: fixedPrecision ? this.precisionValue : 0,
|
|
109
|
+
maximumFractionDigits: this.precisionValue
|
|
110
|
+
});
|
|
111
|
+
return formatter.format(value);
|
|
112
|
+
}
|
|
113
|
+
/** The text announced to assistive tech (currency-aware when configured). */
|
|
114
|
+
#accessibleText(value) {
|
|
115
|
+
if (this.currencyValue) {
|
|
116
|
+
return new Intl.NumberFormat(this.localeValue, {
|
|
117
|
+
style: "currency",
|
|
118
|
+
currency: this.currencyValue
|
|
119
|
+
}).format(value);
|
|
120
|
+
}
|
|
121
|
+
return new Intl.NumberFormat(this.localeValue, {
|
|
122
|
+
minimumFractionDigits: this.precisionValue,
|
|
123
|
+
maximumFractionDigits: this.precisionValue
|
|
124
|
+
}).format(value);
|
|
125
|
+
}
|
|
126
|
+
/** Resolves the locale's grouping and decimal separator characters. */
|
|
127
|
+
#separators() {
|
|
128
|
+
const parts = new Intl.NumberFormat(this.localeValue).formatToParts(11111.1);
|
|
129
|
+
const group = parts.find((p) => p.type === "group")?.value ?? ",";
|
|
130
|
+
const decimal = parts.find((p) => p.type === "decimal")?.value ?? ".";
|
|
131
|
+
return { group, decimal };
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
function countDigits(text) {
|
|
135
|
+
let count = 0;
|
|
136
|
+
for (const ch of text) if (ch >= "0" && ch <= "9") count += 1;
|
|
137
|
+
return count;
|
|
138
|
+
}
|
|
139
|
+
function round(value, precision) {
|
|
140
|
+
const factor = 10 ** Math.max(0, precision);
|
|
141
|
+
const rounded = Math.round(value * factor) / factor;
|
|
142
|
+
return rounded === 0 ? 0 : rounded;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export { CurrencyInputController };
|
|
146
|
+
//# sourceMappingURL=currency_input_controller.js.map
|
|
147
|
+
//# sourceMappingURL=currency_input_controller.js.map
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/data_grid_controller.ts
|
|
4
|
+
var SORT_CYCLE = ["none", "ascending", "descending"];
|
|
5
|
+
function nextSortDirection(current) {
|
|
6
|
+
const index = SORT_CYCLE.indexOf(current);
|
|
7
|
+
const from = index < 0 ? 0 : index;
|
|
8
|
+
return SORT_CYCLE[(from + 1) % SORT_CYCLE.length] ?? "ascending";
|
|
9
|
+
}
|
|
10
|
+
var DataGridController = class extends Controller {
|
|
11
|
+
static targets = ["columnHeader", "row", "cell"];
|
|
12
|
+
static values = {
|
|
13
|
+
selection: { type: String, default: "none" }
|
|
14
|
+
};
|
|
15
|
+
static actions = ["onKeydown", "sort", "toggleSelect"];
|
|
16
|
+
static events = ["selectionchange", "sort"];
|
|
17
|
+
/** Establishes a single tab stop across all navigable cells/headers. */
|
|
18
|
+
connect() {
|
|
19
|
+
const cells = this.#navigableCells();
|
|
20
|
+
const active = cells.find((cell) => cell.tabIndex === 0) ?? cells[0];
|
|
21
|
+
this.#setActiveCell(active, { focus: false });
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Keeps `aria-multiselectable` in step with the `selection` Value. Fires on connect
|
|
25
|
+
* (so it self-heals after a Turbo morph) and on any runtime change, so the ARIA
|
|
26
|
+
* never drifts from the selection logic, which reads `selectionValue` live.
|
|
27
|
+
*/
|
|
28
|
+
selectionValueChanged() {
|
|
29
|
+
this.#syncSelectable();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Mirrors `selection="multiple"` onto `aria-multiselectable` (APG Grid) so SRs
|
|
33
|
+
* announce that more than one row can be selected; cleared for single/none so a
|
|
34
|
+
* grid never carries a misleading attribute.
|
|
35
|
+
*/
|
|
36
|
+
#syncSelectable() {
|
|
37
|
+
if (this.selectionValue === "multiple") {
|
|
38
|
+
this.element.setAttribute("aria-multiselectable", "true");
|
|
39
|
+
} else {
|
|
40
|
+
this.element.removeAttribute("aria-multiselectable");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Cycles the activated column header's sort and emits `sort`. */
|
|
44
|
+
sort(event) {
|
|
45
|
+
const header = event.currentTarget;
|
|
46
|
+
if (!this.columnHeaderTargets.includes(header)) return;
|
|
47
|
+
const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
|
|
48
|
+
for (const other of this.columnHeaderTargets) {
|
|
49
|
+
other.setAttribute("aria-sort", other === header ? direction : "none");
|
|
50
|
+
}
|
|
51
|
+
this.#setActiveCell(header, { focus: false });
|
|
52
|
+
this.dispatch("sort", { detail: { column: header, direction } });
|
|
53
|
+
}
|
|
54
|
+
/** Toggles selection of the row owning the event target. Bound optionally. */
|
|
55
|
+
toggleSelect(event) {
|
|
56
|
+
const row = event.currentTarget.closest("[role='row']");
|
|
57
|
+
if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
|
|
58
|
+
}
|
|
59
|
+
/** Grid navigation + sort/select activation. Bound to cells and headers. */
|
|
60
|
+
onKeydown(event) {
|
|
61
|
+
const cell = event.currentTarget;
|
|
62
|
+
const matrix = this.#matrix();
|
|
63
|
+
const position = this.#locate(matrix, cell);
|
|
64
|
+
if (!position) return;
|
|
65
|
+
const [row, col] = position;
|
|
66
|
+
const rowCells = matrix[row] ?? [];
|
|
67
|
+
let target;
|
|
68
|
+
switch (event.key) {
|
|
69
|
+
case "ArrowRight":
|
|
70
|
+
target = this.#cellInRow(matrix, row, col + 1);
|
|
71
|
+
break;
|
|
72
|
+
case "ArrowLeft":
|
|
73
|
+
target = this.#cellInRow(matrix, row, Math.max(col - 1, 0));
|
|
74
|
+
break;
|
|
75
|
+
case "ArrowDown":
|
|
76
|
+
target = this.#cellInRow(matrix, Math.min(row + 1, matrix.length - 1), col);
|
|
77
|
+
break;
|
|
78
|
+
case "ArrowUp":
|
|
79
|
+
target = this.#cellInRow(matrix, Math.max(row - 1, 0), col);
|
|
80
|
+
break;
|
|
81
|
+
case "Home":
|
|
82
|
+
target = event.ctrlKey ? this.#cellInRow(matrix, 0, 0) : rowCells[0];
|
|
83
|
+
break;
|
|
84
|
+
case "End":
|
|
85
|
+
target = event.ctrlKey ? this.#cellInRow(matrix, matrix.length - 1, Number.POSITIVE_INFINITY) : rowCells[rowCells.length - 1];
|
|
86
|
+
break;
|
|
87
|
+
case "Enter":
|
|
88
|
+
case " ":
|
|
89
|
+
this.#activate(cell);
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
return;
|
|
92
|
+
default:
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (target) {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
this.#setActiveCell(target, { focus: true });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Performs a header's sort or a cell row's selection toggle on activation. */
|
|
101
|
+
#activate(cell) {
|
|
102
|
+
if (this.columnHeaderTargets.includes(cell)) {
|
|
103
|
+
this.#cycleSort(cell);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (this.selectionValue === "none") return;
|
|
107
|
+
const row = cell.closest("[role='row']");
|
|
108
|
+
if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
|
|
109
|
+
}
|
|
110
|
+
/** Shared sort logic for both click and keyboard activation. */
|
|
111
|
+
#cycleSort(header) {
|
|
112
|
+
const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
|
|
113
|
+
for (const other of this.columnHeaderTargets) {
|
|
114
|
+
other.setAttribute("aria-sort", other === header ? direction : "none");
|
|
115
|
+
}
|
|
116
|
+
this.dispatch("sort", { detail: { column: header, direction } });
|
|
117
|
+
}
|
|
118
|
+
/** Toggles a row's `aria-selected`, honoring single vs. multiple selection. */
|
|
119
|
+
#toggleRow(row) {
|
|
120
|
+
const selected = row.getAttribute("aria-selected") === "true";
|
|
121
|
+
if (this.selectionValue === "single" && !selected) {
|
|
122
|
+
for (const other of this.rowTargets) other.setAttribute("aria-selected", "false");
|
|
123
|
+
}
|
|
124
|
+
row.setAttribute("aria-selected", selected ? "false" : "true");
|
|
125
|
+
const rows = this.rowTargets.filter((r) => r.getAttribute("aria-selected") === "true");
|
|
126
|
+
this.dispatch("selectionchange", { detail: { rows } });
|
|
127
|
+
}
|
|
128
|
+
/** Makes `cell` the single tabbable cell (roving) and optionally focuses it. */
|
|
129
|
+
#setActiveCell(cell, { focus }) {
|
|
130
|
+
if (!cell) return;
|
|
131
|
+
for (const candidate of this.#navigableCells()) {
|
|
132
|
+
candidate.tabIndex = candidate === cell ? 0 : -1;
|
|
133
|
+
}
|
|
134
|
+
if (focus) cell.focus();
|
|
135
|
+
}
|
|
136
|
+
/** All navigable elements (headers + cells) in DOM order. */
|
|
137
|
+
#navigableCells() {
|
|
138
|
+
return this.#matrix().flat();
|
|
139
|
+
}
|
|
140
|
+
/** The grid as rows of navigable cells, derived from each `role="row"`. */
|
|
141
|
+
#matrix() {
|
|
142
|
+
const navigable = /* @__PURE__ */ new Set([...this.columnHeaderTargets, ...this.cellTargets]);
|
|
143
|
+
const rows = Array.from(this.element.querySelectorAll("[role='row']"));
|
|
144
|
+
return rows.map(
|
|
145
|
+
(row) => Array.from(row.children).filter(
|
|
146
|
+
(child) => navigable.has(child)
|
|
147
|
+
)
|
|
148
|
+
).filter((cells) => cells.length > 0);
|
|
149
|
+
}
|
|
150
|
+
/** Finds `[rowIndex, colIndex]` of `cell` within `matrix`, or null. */
|
|
151
|
+
#locate(matrix, cell) {
|
|
152
|
+
for (let row = 0; row < matrix.length; row++) {
|
|
153
|
+
const col = (matrix[row] ?? []).indexOf(cell);
|
|
154
|
+
if (col !== -1) return [row, col];
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
/** The cell at `[row, col]`, clamping `col` to that row's last cell. */
|
|
159
|
+
#cellInRow(matrix, row, col) {
|
|
160
|
+
const cells = matrix[row];
|
|
161
|
+
if (!cells || cells.length === 0) return void 0;
|
|
162
|
+
return cells[Math.min(col, cells.length - 1)];
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export { DataGridController };
|
|
167
|
+
//# sourceMappingURL=data_grid_controller.js.map
|
|
168
|
+
//# sourceMappingURL=data_grid_controller.js.map
|