stimeo-ui 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/CHANGELOG.md +89 -0
- package/dist/controllers/bulk_select_controller.d.ts +62 -15
- package/dist/controllers/bulk_select_controller.js +139 -28
- package/dist/controllers/bulk_select_controller.js.map +1 -1
- package/dist/controllers/clipboard_controller.d.ts +48 -15
- package/dist/controllers/clipboard_controller.js +102 -20
- package/dist/controllers/clipboard_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +37 -6
- package/dist/controllers/color_picker_controller.js +180 -43
- package/dist/controllers/color_picker_controller.js.map +1 -1
- package/dist/controllers/data_grid_controller.d.ts +25 -9
- package/dist/controllers/data_grid_controller.js +150 -24
- package/dist/controllers/data_grid_controller.js.map +1 -1
- package/dist/controllers/editable_controller.d.ts +34 -9
- package/dist/controllers/editable_controller.js +83 -30
- package/dist/controllers/editable_controller.js.map +1 -1
- package/dist/controllers/filter_controller.d.ts +15 -3
- package/dist/controllers/filter_controller.js +32 -1
- package/dist/controllers/filter_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +34 -5
- package/dist/controllers/masonry_controller.js +129 -17
- package/dist/controllers/masonry_controller.js.map +1 -1
- package/dist/controllers/otp_controller.d.ts +4 -1
- package/dist/controllers/otp_controller.js +29 -16
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/reset_before_cache_controller.d.ts +25 -2
- package/dist/controllers/reset_before_cache_controller.js +51 -5
- package/dist/controllers/reset_before_cache_controller.js.map +1 -1
- package/dist/controllers/resizable_controller.d.ts +23 -7
- package/dist/controllers/resizable_controller.js +128 -55
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/index.js +860 -339
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.js +2 -0
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +2 -0
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +22 -22
- package/dist/inspector/manifest.json +12 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,94 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
While the version is `0.x`, the public API (the `stimeo--*` data attributes) may
|
|
8
8
|
change between releases.
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-08-28
|
|
11
|
+
|
|
12
|
+
Minor release with no new components. Ten existing ones are reworked —
|
|
13
|
+
bulk-select, clipboard, color-picker, data-grid, editable, filter, masonry, otp,
|
|
14
|
+
reset-before-cache, and resizable — and of those, bulk-select, clipboard,
|
|
15
|
+
data-grid, and editable also changed their markup contracts, so read Removed and
|
|
16
|
+
Changed before upgrading. The Inspector manifest stays on schema v12.
|
|
17
|
+
|
|
18
|
+
### Removed
|
|
19
|
+
|
|
20
|
+
- bulk-select: `announce`, and the `aria-live` on the `bar` target that went with
|
|
21
|
+
it — the count goes through the page's `stimeo--announcer` now.
|
|
22
|
+
- clipboard: the live-region requirement on the `feedback` target. Drop the
|
|
23
|
+
`role="status"` and `aria-live`; the slot is a plain visible label.
|
|
24
|
+
- editable: the `onBlur` action. Drop `blur->stimeo--editable#onBlur` — the
|
|
25
|
+
departure is watched on the controller element.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- bulk-select: `announceText`, carrying `{count}`, and a `reconcile` event.
|
|
30
|
+
- clipboard: `announceCopiedText` and `announceErrorText`. Like `announceText`
|
|
31
|
+
they default to empty, so seat a `stimeo--announcer` on the page and write the
|
|
32
|
+
wording you want read.
|
|
33
|
+
- editable: `save` / `cancel` / `revert` actions, and `data-value` on the
|
|
34
|
+
`display` target for a display that renders the value rather than being it.
|
|
35
|
+
- color-picker: `data-value-text`, an `aria-valuetext` template whose `{value}`
|
|
36
|
+
carries the channel value, for pages that are not in English.
|
|
37
|
+
- Declarations read once are followed when they change — filter's `match`,
|
|
38
|
+
masonry's `minColumnWidth` / `gap`, resizable's `min` / `max` — as are targets
|
|
39
|
+
swapped in later by color-picker, editable, masonry, and resizable, and masonry
|
|
40
|
+
also relays out when a descendant resource finishes loading.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- bulk-select: `selectAllPages` checks the rows on the page too, so a row that
|
|
45
|
+
arrives while the mode is on lands checked. Hiding the bar hands focus to the
|
|
46
|
+
select-all box, and a `bar` carrying `role="toolbar"` wants `stimeo--toolbar`
|
|
47
|
+
alongside it for the arrow keys and the single tab stop.
|
|
48
|
+
- clipboard: `copied` and `error` are transient `data-state` values — a
|
|
49
|
+
`connect()` that finds either returns to `idle`, and the state is rewound
|
|
50
|
+
before Turbo caches the page. Any other authored `data-state` is left alone.
|
|
51
|
+
- color-picker: `aria-valuenow` / `aria-valuetext` / `aria-valuemin` /
|
|
52
|
+
`aria-valuemax` are controller-owned, so the markup supplies `role="slider"`
|
|
53
|
+
and a name; an omitted range resolves to the channel default (hue `0`–`360`,
|
|
54
|
+
the rest `0`–`100`). Only a primary-button press drags, and an `alpha` slider
|
|
55
|
+
is inert unless `alpha` is on.
|
|
56
|
+
- data-grid: a keystroke or click that reached a control inside a cell, and one
|
|
57
|
+
an IME is composing, belongs to that control — no move, no sort, no selection.
|
|
58
|
+
The exception is a click on a sortable header's own `<button>`, which needs
|
|
59
|
+
`tabindex="-1"`; host `gridcell` / `columnheader` on `td` / `th`.
|
|
60
|
+
- editable: the mode is read back from the DOM and the controller writes no ARIA.
|
|
61
|
+
Focus landing inside the component has not left the edit, so a Save or Cancel
|
|
62
|
+
button beside the input keeps it open, and the value is trimmed once at the save.
|
|
63
|
+
- reset-before-cache: `data-reset-value` returns a field to what the markup
|
|
64
|
+
declares — `defaultValue`, `defaultChecked`, the `selected` option — instead of
|
|
65
|
+
emptying it; a file input empties and `hidden` / `submit` / `reset` / `button` /
|
|
66
|
+
`image` are left alone.
|
|
67
|
+
- resizable: a separator with no `aria-orientation` reads as `horizontal`, and
|
|
68
|
+
`toggle` reopens at the position the pane collapsed from. Pointer capture and
|
|
69
|
+
the drag listeners sit on the `separator` target, not on `event.target` and
|
|
70
|
+
`document`.
|
|
71
|
+
- masonry: `layout` fires when the result moves, not only on a column count
|
|
72
|
+
change, and every relayout after the first is deferred to a microtask. otp:
|
|
73
|
+
`reconcile` reports a move in the pair (joined value, `data-state`).
|
|
74
|
+
- A declaration that cannot be read falls back to its default instead of reaching
|
|
75
|
+
ARIA or CSS as `NaN` — masonry's `minColumnWidth` / `gap`, resizable's `min` /
|
|
76
|
+
`max` / `value` / `step`, bulk-select's `totalCount`, and reset-before-cache's
|
|
77
|
+
`scope`, which no longer aborts the whole sweep.
|
|
78
|
+
- Inspector: clipboard's `feedback` is no longer required, a color-picker
|
|
79
|
+
`slider` is checked for `role` and a name only, and bulk-select and clipboard
|
|
80
|
+
join the components that need a `stimeo--announcer`.
|
|
81
|
+
|
|
82
|
+
### Fixed
|
|
83
|
+
|
|
84
|
+
- data-grid: `Enter` and `Space` on a `<button>` nested in a cell reach it, so a
|
|
85
|
+
row-action button is keyboard-reachable, and rows coming and going at runtime
|
|
86
|
+
re-establish exactly one tab stop. editable: tabbing past a Save or Cancel
|
|
87
|
+
button no longer strands the editor open, and a display removed mid-edit no
|
|
88
|
+
longer throws. bulk-select: a nested bulk-select's rows stay out of the outer
|
|
89
|
+
container's count.
|
|
90
|
+
- clipboard: a copy that resolved after disconnect wrote to the detached element
|
|
91
|
+
and armed a timer past the teardown, and a repeat inside the feedback window is
|
|
92
|
+
read out again. In color-picker a drag belongs to the pointer that started it,
|
|
93
|
+
in masonry an item removed and re-appended in one batch keeps its layout hooks,
|
|
94
|
+
and in resizable `data-dragging` no longer rides a Turbo snapshot.
|
|
95
|
+
- reset-before-cache: `data-reset-value` wrote an empty string into a checkbox's
|
|
96
|
+
`value` attribute, and could leave a select with nothing selected at all.
|
|
97
|
+
|
|
10
98
|
## [0.8.0] - 2026-08-22
|
|
11
99
|
|
|
12
100
|
Minor release with no new components. Eight existing ones are reworked —
|
|
@@ -695,6 +783,7 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
|
|
|
695
783
|
by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
|
|
696
784
|
RubyGems.
|
|
697
785
|
|
|
786
|
+
[0.9.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.9.0
|
|
698
787
|
[0.8.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.8.0
|
|
699
788
|
[0.7.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.7.0
|
|
700
789
|
[0.6.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.6.0
|
|
@@ -9,31 +9,59 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
9
9
|
*
|
|
10
10
|
* Markup contract (identifier: `stimeo--bulk-select`):
|
|
11
11
|
* <div data-controller="stimeo--bulk-select"
|
|
12
|
-
* data-stimeo--bulk-select-total-count-value="128"
|
|
12
|
+
* data-stimeo--bulk-select-total-count-value="128"
|
|
13
|
+
* data-stimeo--bulk-select-announce-text-value="{count} selected">
|
|
13
14
|
* <input type="checkbox" data-stimeo--bulk-select-target="all">
|
|
14
15
|
* <!-- rows (may be added dynamically; handled via event delegation) -->
|
|
15
16
|
* <input type="checkbox" data-stimeo--bulk-select-target="item">
|
|
16
17
|
* <input type="checkbox" data-stimeo--bulk-select-target="item">
|
|
17
|
-
* <div data-stimeo--bulk-select-target="bar" hidden role="toolbar"
|
|
18
|
+
* <div data-stimeo--bulk-select-target="bar" hidden role="toolbar"
|
|
19
|
+
* data-controller="stimeo--toolbar" aria-label="Bulk actions">
|
|
18
20
|
* <span data-stimeo--bulk-select-target="count"></span> selected
|
|
19
21
|
* <button data-stimeo--bulk-select-target="selectAllPages"
|
|
22
|
+
* data-stimeo--toolbar-target="control"
|
|
20
23
|
* data-action="click->stimeo--bulk-select#selectAllPages">Select all</button>
|
|
21
|
-
* <button data-
|
|
24
|
+
* <button data-stimeo--toolbar-target="control"
|
|
25
|
+
* data-action="click->stimeo--bulk-select#clear">Clear</button>
|
|
22
26
|
* </div>
|
|
23
27
|
* </div>
|
|
24
28
|
*
|
|
25
|
-
* `
|
|
29
|
+
* The bar carries `role="toolbar"`, so it is composed with `stimeo--toolbar` to get
|
|
30
|
+
* the arrow-key movement and single tab stop that role calls for.
|
|
31
|
+
*
|
|
32
|
+
* `change` and `reconcile` both dispatch `{ count, allPages }`.
|
|
33
|
+
*
|
|
34
|
+
* Selection is two-stage. The select-all box covers the rows on this page; the
|
|
35
|
+
* optional `selectAllPages` control extends the selection to `totalCount` rows
|
|
36
|
+
* across every page, checking each row here as it does so. Touching any single row
|
|
37
|
+
* leaves that mode, because the selection is no longer the whole set.
|
|
26
38
|
*
|
|
27
39
|
* @remarks
|
|
28
40
|
* Behavior only — it never runs the batch action (that is the consumer's
|
|
29
41
|
* form/Turbo) nor fetches/pages rows. Selection lives **only** in each checkbox's
|
|
30
42
|
* `checked` (no module-scope set), so `connect()` recomputes idempotently from the
|
|
31
43
|
* DOM after a Turbo swap. Row `change` is handled by **delegation** on the
|
|
32
|
-
* container, so dynamically-added rows work without per-row `data-action
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
44
|
+
* container, so dynamically-added rows work without per-row `data-action`, while
|
|
45
|
+
* rows arriving or leaving on their own are picked up by the target callbacks and
|
|
46
|
+
* coalesced into one repair per batch.
|
|
47
|
+
*
|
|
48
|
+
* `change` is dispatched when the user moves the selection; `reconcile` when the
|
|
49
|
+
* controller repairs the figures itself — a row added or removed by the page, or a
|
|
50
|
+
* render input changing at runtime. Both carry the same detail.
|
|
51
|
+
*
|
|
52
|
+
* The count reaches assistive tech through the page's shared `stimeo--announcer`,
|
|
53
|
+
* worded by the consumer via `announceText` (`{count}` expands to the figure being
|
|
54
|
+
* shown; an empty template stays silent, keeping announcements opt-in and
|
|
55
|
+
* i18n-neutral).
|
|
56
|
+
*
|
|
57
|
+
* Showing the bar never steals focus (WCAG 2.2 2.4.3). Hiding it hands focus to
|
|
58
|
+
* the select-all box first when the bar holds it, so the keyboard user keeps a Tab
|
|
59
|
+
* position instead of falling back to the document.
|
|
60
|
+
*
|
|
61
|
+
* A non-finite `totalCount` falls back to the Value's default and is written back
|
|
62
|
+
* to the attribute, so the count never renders as `NaN`.
|
|
63
|
+
*
|
|
64
|
+
* The delegated listener and the pending repair are both released on `disconnect()`.
|
|
37
65
|
*/
|
|
38
66
|
declare class BulkSelectController extends Controller<HTMLElement> {
|
|
39
67
|
#private;
|
|
@@ -43,26 +71,45 @@ declare class BulkSelectController extends Controller<HTMLElement> {
|
|
|
43
71
|
type: NumberConstructor;
|
|
44
72
|
default: number;
|
|
45
73
|
};
|
|
46
|
-
|
|
47
|
-
type:
|
|
48
|
-
default:
|
|
74
|
+
announceText: {
|
|
75
|
+
type: StringConstructor;
|
|
76
|
+
default: string;
|
|
49
77
|
};
|
|
50
78
|
};
|
|
51
79
|
static actions: readonly ["clear", "selectAllPages"];
|
|
52
|
-
static events: readonly ["change"];
|
|
80
|
+
static events: readonly ["change", "reconcile"];
|
|
53
81
|
readonly allTarget: HTMLInputElement;
|
|
54
82
|
readonly barTarget: HTMLElement;
|
|
55
83
|
readonly countTarget: HTMLElement;
|
|
84
|
+
readonly itemTargets: HTMLInputElement[];
|
|
56
85
|
readonly hasAllTarget: boolean;
|
|
57
86
|
readonly hasBarTarget: boolean;
|
|
58
87
|
readonly hasCountTarget: boolean;
|
|
59
88
|
totalCountValue: number;
|
|
60
|
-
|
|
89
|
+
announceTextValue: string;
|
|
61
90
|
connect(): void;
|
|
62
91
|
disconnect(): void;
|
|
92
|
+
/** Repairs the figures for a row that arrived at runtime. */
|
|
93
|
+
itemTargetConnected(): void;
|
|
94
|
+
/** Repairs the figures after a row leaves, so a removed selection stops counting. */
|
|
95
|
+
itemTargetDisconnected(): void;
|
|
96
|
+
/** Reflects the current selection onto a select-all box added at runtime. */
|
|
97
|
+
allTargetConnected(): void;
|
|
98
|
+
/** Repairs the figures after the select-all box leaves. */
|
|
99
|
+
allTargetDisconnected(): void;
|
|
100
|
+
/** Repaints the count for a total that changed at runtime, rejecting non-finite ones. */
|
|
101
|
+
totalCountValueChanged(): void;
|
|
102
|
+
/** Repaints so wording changed at runtime is used by the next announcement. */
|
|
103
|
+
announceTextValueChanged(): void;
|
|
63
104
|
/** Clears every selection (rows + select-all) and exits all-pages mode. */
|
|
64
105
|
clear(): void;
|
|
65
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Enters "select all across pages" mode: the count shows `totalCount`, and every
|
|
108
|
+
* row on this page is checked.
|
|
109
|
+
*
|
|
110
|
+
* The mode's claim is that the whole set is selected, so leaving a visible row
|
|
111
|
+
* unchecked would put the page and the count in open disagreement.
|
|
112
|
+
*/
|
|
66
113
|
selectAllPages(): void;
|
|
67
114
|
}
|
|
68
115
|
|
|
@@ -1,72 +1,189 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/bulk_select_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/announce.ts
|
|
6
|
+
function announce(message, options = {}) {
|
|
7
|
+
const text = message.trim();
|
|
8
|
+
if (text.length === 0) return;
|
|
9
|
+
window.dispatchEvent(
|
|
10
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
11
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
function fillTemplate(template, values) {
|
|
16
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
17
|
+
const replacement = values[name];
|
|
18
|
+
return replacement === void 0 ? match : String(replacement);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/utils/microtask_coalescer.ts
|
|
23
|
+
var MicrotaskCoalescer = class {
|
|
24
|
+
#run;
|
|
25
|
+
#queued = false;
|
|
26
|
+
#active = false;
|
|
27
|
+
#generation = 0;
|
|
28
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
29
|
+
constructor(run) {
|
|
30
|
+
this.#run = run;
|
|
31
|
+
}
|
|
32
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
33
|
+
activate() {
|
|
34
|
+
this.#active = true;
|
|
35
|
+
}
|
|
36
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
37
|
+
cancel() {
|
|
38
|
+
this.#active = false;
|
|
39
|
+
this.#queued = false;
|
|
40
|
+
this.#generation += 1;
|
|
41
|
+
}
|
|
42
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
43
|
+
schedule() {
|
|
44
|
+
if (!this.#active || this.#queued) return;
|
|
45
|
+
this.#queued = true;
|
|
46
|
+
const generation = this.#generation;
|
|
47
|
+
queueMicrotask(() => {
|
|
48
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
49
|
+
this.#queued = false;
|
|
50
|
+
this.#run();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
3
55
|
// src/controllers/bulk_select_controller.ts
|
|
4
56
|
var BulkSelectController = class extends Controller {
|
|
5
57
|
static targets = ["all", "item", "bar", "count", "selectAllPages"];
|
|
6
58
|
static values = {
|
|
7
59
|
totalCount: { type: Number, default: 0 },
|
|
8
|
-
|
|
60
|
+
announceText: { type: String, default: "" }
|
|
9
61
|
};
|
|
10
62
|
static actions = ["clear", "selectAllPages"];
|
|
11
|
-
static events = ["change"];
|
|
12
|
-
/** All-pages mode is a transient UI state
|
|
13
|
-
*
|
|
63
|
+
static events = ["change", "reconcile"];
|
|
64
|
+
/** All-pages mode is a transient UI state, mirrored to `data-all-pages` so a
|
|
65
|
+
* `connect()` over markup that already carries the attribute rehydrates the
|
|
66
|
+
* mode — a morph, a Turbo Stream, or a server that renders it back. A restore
|
|
67
|
+
* visit serves the server's markup instead, so the mode does not survive one. */
|
|
14
68
|
#allPagesMode = false;
|
|
15
|
-
/** Last emitted figures, so a recompute
|
|
69
|
+
/** Last emitted figures, so a recompute reports only on a real change. */
|
|
16
70
|
#lastCount = -1;
|
|
17
71
|
#lastAllPages = false;
|
|
72
|
+
/** Collapses every signal from one DOM or Value update into one repair. */
|
|
73
|
+
#reconcile = new MicrotaskCoalescer(() => this.#reconcileNow());
|
|
18
74
|
/** Delegated `change` handler covering the select-all box and every row. */
|
|
19
75
|
#onChange = (event) => {
|
|
20
76
|
const target = event.target;
|
|
21
77
|
if (!target) return;
|
|
22
78
|
if (this.hasAllTarget && target === this.allTarget) {
|
|
23
79
|
this.#applyAll();
|
|
24
|
-
} else if (
|
|
80
|
+
} else if (this.itemTargets.some((item) => item === target)) {
|
|
25
81
|
this.#exitAllPages();
|
|
26
|
-
this.#recompute(
|
|
82
|
+
this.#reportChange(this.#recompute());
|
|
27
83
|
}
|
|
28
84
|
};
|
|
29
85
|
connect() {
|
|
30
86
|
this.#allPagesMode = this.element.dataset.allPages === "true";
|
|
87
|
+
this.#reconcile.activate();
|
|
31
88
|
this.element.addEventListener("change", this.#onChange);
|
|
32
|
-
this.#recompute(
|
|
89
|
+
this.#recompute();
|
|
33
90
|
}
|
|
34
91
|
disconnect() {
|
|
35
92
|
this.element.removeEventListener("change", this.#onChange);
|
|
93
|
+
this.#reconcile.cancel();
|
|
94
|
+
}
|
|
95
|
+
/** Repairs the figures for a row that arrived at runtime. */
|
|
96
|
+
itemTargetConnected() {
|
|
97
|
+
this.#reconcile.schedule();
|
|
98
|
+
}
|
|
99
|
+
/** Repairs the figures after a row leaves, so a removed selection stops counting. */
|
|
100
|
+
itemTargetDisconnected() {
|
|
101
|
+
this.#reconcile.schedule();
|
|
102
|
+
}
|
|
103
|
+
/** Reflects the current selection onto a select-all box added at runtime. */
|
|
104
|
+
allTargetConnected() {
|
|
105
|
+
this.#reconcile.schedule();
|
|
106
|
+
}
|
|
107
|
+
/** Repairs the figures after the select-all box leaves. */
|
|
108
|
+
allTargetDisconnected() {
|
|
109
|
+
this.#reconcile.schedule();
|
|
110
|
+
}
|
|
111
|
+
/** Repaints the count for a total that changed at runtime, rejecting non-finite ones. */
|
|
112
|
+
totalCountValueChanged() {
|
|
113
|
+
if (!Number.isFinite(this.totalCountValue)) {
|
|
114
|
+
this.totalCountValue = 0;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
this.#reconcile.schedule();
|
|
118
|
+
}
|
|
119
|
+
/** Repaints so wording changed at runtime is used by the next announcement. */
|
|
120
|
+
announceTextValueChanged() {
|
|
121
|
+
this.#reconcile.schedule();
|
|
36
122
|
}
|
|
37
123
|
/** Clears every selection (rows + select-all) and exits all-pages mode. */
|
|
38
124
|
clear() {
|
|
39
|
-
for (const item of this
|
|
125
|
+
for (const item of this.itemTargets) item.checked = false;
|
|
40
126
|
if (this.hasAllTarget) {
|
|
41
127
|
this.allTarget.checked = false;
|
|
42
128
|
this.allTarget.indeterminate = false;
|
|
43
129
|
}
|
|
44
130
|
this.#exitAllPages();
|
|
45
|
-
this.#recompute(
|
|
131
|
+
this.#reportChange(this.#recompute());
|
|
46
132
|
}
|
|
47
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* Enters "select all across pages" mode: the count shows `totalCount`, and every
|
|
135
|
+
* row on this page is checked.
|
|
136
|
+
*
|
|
137
|
+
* The mode's claim is that the whole set is selected, so leaving a visible row
|
|
138
|
+
* unchecked would put the page and the count in open disagreement.
|
|
139
|
+
*/
|
|
48
140
|
selectAllPages() {
|
|
49
141
|
this.#allPagesMode = true;
|
|
50
|
-
this.#
|
|
142
|
+
this.#checkEveryRow();
|
|
143
|
+
this.#reportChange(this.#recompute());
|
|
144
|
+
}
|
|
145
|
+
/** Marks every row on this page selected. */
|
|
146
|
+
#checkEveryRow() {
|
|
147
|
+
for (const item of this.itemTargets) item.checked = true;
|
|
51
148
|
}
|
|
52
149
|
/** Mirrors the select-all box to every row, then recomputes. */
|
|
53
150
|
#applyAll() {
|
|
54
151
|
if (!this.hasAllTarget) return;
|
|
55
152
|
const { checked } = this.allTarget;
|
|
56
|
-
for (const item of this
|
|
153
|
+
for (const item of this.itemTargets) item.checked = checked;
|
|
57
154
|
this.#exitAllPages();
|
|
58
|
-
this.#recompute(
|
|
155
|
+
this.#reportChange(this.#recompute());
|
|
59
156
|
}
|
|
60
157
|
#exitAllPages() {
|
|
61
158
|
this.#allPagesMode = false;
|
|
62
159
|
}
|
|
160
|
+
/** Repairs the derived state after the page moved rows or a render input. */
|
|
161
|
+
#reconcileNow() {
|
|
162
|
+
if (this.#allPagesMode) this.#checkEveryRow();
|
|
163
|
+
const detail = this.#recompute();
|
|
164
|
+
if (!detail) return;
|
|
165
|
+
this.dispatch("reconcile", { detail });
|
|
166
|
+
this.#announce(detail);
|
|
167
|
+
}
|
|
168
|
+
/** Reports a selection the user moved. */
|
|
169
|
+
#reportChange(detail) {
|
|
170
|
+
if (!detail) return;
|
|
171
|
+
this.dispatch("change", { detail });
|
|
172
|
+
this.#announce(detail);
|
|
173
|
+
}
|
|
174
|
+
/** Hands the count to the shared announcer, worded by the consumer. */
|
|
175
|
+
#announce(detail) {
|
|
176
|
+
announce(fillTemplate(this.announceTextValue, { count: detail.count }));
|
|
177
|
+
}
|
|
63
178
|
/**
|
|
64
179
|
* Recomputes the count, the select-all checked/indeterminate state, and the bar
|
|
65
|
-
* visibility from the current DOM.
|
|
66
|
-
*
|
|
180
|
+
* visibility from the current DOM. Returns the figures when the emitted count or
|
|
181
|
+
* all-pages flag actually moved, and `null` when they did not.
|
|
182
|
+
*
|
|
183
|
+
* @stimeoRenderRoot
|
|
67
184
|
*/
|
|
68
|
-
#recompute(
|
|
69
|
-
const items = this
|
|
185
|
+
#recompute() {
|
|
186
|
+
const items = this.itemTargets;
|
|
70
187
|
const total = items.length;
|
|
71
188
|
const checked = items.filter((item) => item.checked).length;
|
|
72
189
|
const allPages = this.#allPagesMode;
|
|
@@ -77,8 +194,10 @@ var BulkSelectController = class extends Controller {
|
|
|
77
194
|
const count = allPages ? this.totalCountValue : checked;
|
|
78
195
|
const show = allPages || checked > 0;
|
|
79
196
|
if (this.hasBarTarget) {
|
|
197
|
+
if (!show && this.hasAllTarget && this.barTarget.contains(document.activeElement)) {
|
|
198
|
+
this.allTarget.focus();
|
|
199
|
+
}
|
|
80
200
|
this.barTarget.hidden = !show;
|
|
81
|
-
this.barTarget.setAttribute("aria-live", this.announceValue ? "polite" : "off");
|
|
82
201
|
}
|
|
83
202
|
if (this.hasCountTarget) this.countTarget.textContent = String(count);
|
|
84
203
|
this.element.setAttribute("data-selected-count", String(checked));
|
|
@@ -87,15 +206,7 @@ var BulkSelectController = class extends Controller {
|
|
|
87
206
|
const changed = count !== this.#lastCount || allPages !== this.#lastAllPages;
|
|
88
207
|
this.#lastCount = count;
|
|
89
208
|
this.#lastAllPages = allPages;
|
|
90
|
-
|
|
91
|
-
this.dispatch("change", { detail: { count, allPages } });
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
/** Live list of row checkboxes, queried from the DOM so dynamic rows count. */
|
|
95
|
-
get #items() {
|
|
96
|
-
return Array.from(
|
|
97
|
-
this.element.querySelectorAll('[data-stimeo--bulk-select-target="item"]')
|
|
98
|
-
);
|
|
209
|
+
return changed ? { count, allPages } : null;
|
|
99
210
|
}
|
|
100
211
|
};
|
|
101
212
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/bulk_select_controller.ts"],"names":[],"mappings":"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","file":"bulk_select_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless **bulk select / batch action bar** (no dedicated APG pattern — a\n * composition of a checkbox group and a toolbar). Mirrors the Gmail/admin UX where\n * checking one or more rows reveals a sticky action bar with the selected count,\n * select-all, and clear. Data Grid owns per-row `aria-selected`; this is the\n * contextual action-bar layer on top.\n *\n * Markup contract (identifier: `stimeo--bulk-select`):\n * <div data-controller=\"stimeo--bulk-select\"\n * data-stimeo--bulk-select-total-count-value=\"128\">\n * <input type=\"checkbox\" data-stimeo--bulk-select-target=\"all\">\n * <!-- rows (may be added dynamically; handled via event delegation) -->\n * <input type=\"checkbox\" data-stimeo--bulk-select-target=\"item\">\n * <input type=\"checkbox\" data-stimeo--bulk-select-target=\"item\">\n * <div data-stimeo--bulk-select-target=\"bar\" hidden role=\"toolbar\" aria-live=\"polite\">\n * <span data-stimeo--bulk-select-target=\"count\"></span> selected\n * <button data-stimeo--bulk-select-target=\"selectAllPages\"\n * data-action=\"click->stimeo--bulk-select#selectAllPages\">Select all</button>\n * <button data-action=\"click->stimeo--bulk-select#clear\">Clear</button>\n * </div>\n * </div>\n *\n * `change` dispatches `{ count, allPages }`.\n *\n * @remarks\n * Behavior only — it never runs the batch action (that is the consumer's\n * form/Turbo) nor fetches/pages rows. Selection lives **only** in each checkbox's\n * `checked` (no module-scope set), so `connect()` recomputes idempotently from the\n * DOM after a Turbo swap. Row `change` is handled by **delegation** on the\n * container, so dynamically-added rows work without per-row `data-action`.\n * Showing the bar never steals focus\n * (WCAG 2.2 2.4.3); the count rides the bar's own `aria-live` region (WCAG 2.2\n * 4.1.3) — the bar is revealed *before* the count text is written so the change is\n * observed and announced. The delegated listener is removed on `disconnect()`.\n */\nexport class BulkSelectController extends Controller<HTMLElement> {\n static override targets = [\"all\", \"item\", \"bar\", \"count\", \"selectAllPages\"];\n static override values = {\n totalCount: { type: Number, default: 0 },\n announce: { type: Boolean, default: true },\n };\n static actions = [\"clear\", \"selectAllPages\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly allTarget: HTMLInputElement;\n declare readonly barTarget: HTMLElement;\n declare readonly countTarget: HTMLElement;\n declare readonly hasAllTarget: boolean;\n declare readonly hasBarTarget: boolean;\n declare readonly hasCountTarget: boolean;\n\n declare totalCountValue: number;\n declare announceValue: boolean;\n\n /** All-pages mode is a transient UI state; mirrored to `data-all-pages` so it\n * survives a Turbo swap and `connect()` can rehydrate it. */\n #allPagesMode = false;\n /** Last emitted figures, so a recompute dispatches `change` only on real change. */\n #lastCount = -1;\n #lastAllPages = false;\n\n /** Delegated `change` handler covering the select-all box and every row. */\n readonly #onChange = (event: Event): void => {\n const target = event.target as HTMLElement | null;\n if (!target) return;\n if (this.hasAllTarget && target === this.allTarget) {\n this.#applyAll();\n } else if (target.matches('[data-stimeo--bulk-select-target=\"item\"]')) {\n this.#exitAllPages();\n this.#recompute(true);\n }\n };\n\n override connect(): void {\n this.#allPagesMode = this.element.dataset.allPages === \"true\";\n this.element.addEventListener(\"change\", this.#onChange);\n this.#recompute(false);\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"change\", this.#onChange);\n }\n\n /** Clears every selection (rows + select-all) and exits all-pages mode. */\n clear(): void {\n for (const item of this.#items) item.checked = false;\n if (this.hasAllTarget) {\n this.allTarget.checked = false;\n this.allTarget.indeterminate = false;\n }\n this.#exitAllPages();\n this.#recompute(true);\n }\n\n /** Enters \"select all across pages\" mode (count shows `totalCount`). */\n selectAllPages(): void {\n this.#allPagesMode = true;\n this.#recompute(true);\n }\n\n /** Mirrors the select-all box to every row, then recomputes. */\n #applyAll(): void {\n if (!this.hasAllTarget) return;\n const { checked } = this.allTarget;\n for (const item of this.#items) item.checked = checked;\n this.#exitAllPages();\n this.#recompute(true);\n }\n\n #exitAllPages(): void {\n this.#allPagesMode = false;\n }\n\n /**\n * Recomputes the count, the select-all checked/indeterminate state, and the bar\n * visibility from the current DOM. Dispatches `change` (when `notify`) only if\n * the emitted count or all-pages flag actually changed.\n */\n #recompute(notify: boolean): void {\n const items = this.#items;\n const total = items.length;\n const checked = items.filter((item) => item.checked).length;\n const allPages = this.#allPagesMode;\n\n if (this.hasAllTarget) {\n this.allTarget.checked = total > 0 && checked === total;\n this.allTarget.indeterminate = checked > 0 && checked < total;\n }\n\n const count = allPages ? this.totalCountValue : checked;\n const show = allPages || checked > 0;\n\n // Reveal the bar BEFORE writing the count so its aria-live region observes the\n // text change (a region revealed after its content changed may not announce).\n if (this.hasBarTarget) {\n this.barTarget.hidden = !show;\n this.barTarget.setAttribute(\"aria-live\", this.announceValue ? \"polite\" : \"off\");\n }\n if (this.hasCountTarget) this.countTarget.textContent = String(count);\n\n this.element.setAttribute(\"data-selected-count\", String(checked));\n if (allPages) this.element.setAttribute(\"data-all-pages\", \"true\");\n else this.element.removeAttribute(\"data-all-pages\");\n\n const changed = count !== this.#lastCount || allPages !== this.#lastAllPages;\n this.#lastCount = count;\n this.#lastAllPages = allPages;\n if (notify && changed) {\n this.dispatch(\"change\", { detail: { count, allPages } });\n }\n }\n\n /** Live list of row checkboxes, queried from the DOM so dynamic rows count. */\n get #items(): HTMLInputElement[] {\n return Array.from(\n this.element.querySelectorAll<HTMLInputElement>('[data-stimeo--bulk-select-target=\"item\"]'),\n );\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/announce.ts","../../src/utils/microtask_coalescer.ts","../../src/controllers/bulk_select_controller.ts"],"names":[],"mappings":"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","file":"bulk_select_controller.js","sourcesContent":["/**\n * Sends one message to the page's shared `stimeo--announcer`.\n *\n * A component that has to reach assistive tech does not carry a live region of its\n * own: a region only announces what changes *after* assistive tech already knows\n * about it, which a region that appears (or is un-hidden) with its message cannot\n * satisfy. The one region that can is the announcer sitting in the page from the\n * start, so state changes are handed to it as an event and it does the reading.\n *\n * The event goes to `window` because the announcer is usually a sibling high in the\n * document rather than an ancestor of the component dispatching it.\n *\n * Wording comes from the consumer — the library ships no English strings — so an\n * empty message is silently dropped and nothing is announced.\n *\n * @example\n * ```ts\n * announce(this.announceTextValue, { assertive: false });\n * ```\n */\nexport function announce(message: string, options: { assertive?: boolean } = {}): void {\n const text = message.trim();\n if (text.length === 0) return;\n window.dispatchEvent(\n new CustomEvent(\"stimeo--announcer:announce\", {\n detail: { message: text, assertive: options.assertive === true },\n }),\n );\n}\n\n/**\n * Fills `{name}` placeholders in an announcement template from `values`.\n *\n * The same substitution the value-text templates use, so a consumer writes\n * `\"{percent}% complete\"` in one attribute and gets the same rules everywhere. A\n * placeholder with no matching entry is left as authored rather than blanked, which\n * keeps a typo visible instead of silently swallowing the word.\n */\nexport function fillTemplate(template: string, values: Record<string, string | number>): string {\n return template.replace(/\\{([a-zA-Z][a-zA-Z0-9]*)\\}/g, (match, name: string) => {\n const replacement = values[name];\n return replacement === undefined ? match : String(replacement);\n });\n}\n","/**\n * Collapses many Stimulus lifecycle callbacks from one DOM mutation into a\n * single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element and\n * `<name>ValueChanged` once per changed attribute. Replacing a list of N options\n * or morphing several render Values therefore delivers N callbacks — but the\n * useful unit of work is \"reconcile against the resulting declarative input\",\n * once, after the batch has settled. Every controller with reconcilable targets\n * or render Values needs the same shape: a `queued` flag plus `queueMicrotask`.\n *\n * **A microtask is the right horizon, and the reason is specific.** Stimulus\n * drives these callbacks from a `MutationObserver`, whose own callback already\n * runs as a microtask with the whole batch in hand; scheduling one more lands\n * after the last sibling callback of that batch and still before paint or any\n * event handler. A timer would be later than it needs to be, and reconciling\n * synchronously would run once per element against a half-applied DOM.\n *\n * **The two guards are not the same guard.** Scheduling is refused before the\n * controller connects, and running is refused after it disconnects:\n *\n * - **Before `connect()`** — Stimulus delivers initial target and Value callbacks\n * ahead of `connect()`. Reconciling there would compute output against a\n * controller whose own state has not been initialised, and `connect()` is\n * about to do a full pass anyway.\n * - **After `disconnect()`** — Stimulus fires a callback for **every** target\n * during teardown, and a microtask queued just before it would otherwise run\n * against a detached tree. {@link MicrotaskCoalescer.cancel} exists for the\n * teardown path to drop the pending pass outright.\n *\n * Both guards are part of one contract here rather than something each consumer\n * has to remember separately.\n *\n * Scope is the scheduling only. *What* to reconcile — keep the surviving active\n * option, fall back to the next / previous / first visible one, rebuild derived\n * chips or hidden fields — stays in the controller, because no two consumers\n * answer it the same way.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());\n *\n * connect() { this.#reconcile.activate(); }\n * disconnect() { this.#reconcile.cancel(); }\n *\n * optionTargetConnected() { this.#reconcile.schedule(); }\n * optionTargetDisconnected() { this.#reconcile.schedule(); }\n * ```\n */\nexport class MicrotaskCoalescer {\n readonly #run: () => void;\n #queued = false;\n #active = false;\n #generation = 0;\n\n /** @param run - the single reconciliation pass, invoked at most once per batch. */\n constructor(run: () => void) {\n this.#run = run;\n }\n\n /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */\n activate(): void {\n this.#active = true;\n }\n\n /** Closes the window and drops any pending pass; call from `disconnect()`. */\n cancel(): void {\n this.#active = false;\n this.#queued = false;\n this.#generation += 1;\n }\n\n /** Requests one pass after the batch settles. Idempotent; inert outside the window. */\n schedule(): void {\n if (!this.#active || this.#queued) return;\n this.#queued = true;\n const generation = this.#generation;\n queueMicrotask(() => {\n // A cancelled callback must not consume a pass queued after reconnect.\n if (generation !== this.#generation || !this.#queued || !this.#active) return;\n this.#queued = false;\n this.#run();\n });\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { announce, fillTemplate } from \"../utils/announce\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\n\n/** The selection figures every report carries. */\ntype SelectionDetail = { count: number; allPages: boolean };\n\n/**\n * Headless **bulk select / batch action bar** (no dedicated APG pattern — a\n * composition of a checkbox group and a toolbar). Mirrors the Gmail/admin UX where\n * checking one or more rows reveals a sticky action bar with the selected count,\n * select-all, and clear. Data Grid owns per-row `aria-selected`; this is the\n * contextual action-bar layer on top.\n *\n * Markup contract (identifier: `stimeo--bulk-select`):\n * <div data-controller=\"stimeo--bulk-select\"\n * data-stimeo--bulk-select-total-count-value=\"128\"\n * data-stimeo--bulk-select-announce-text-value=\"{count} selected\">\n * <input type=\"checkbox\" data-stimeo--bulk-select-target=\"all\">\n * <!-- rows (may be added dynamically; handled via event delegation) -->\n * <input type=\"checkbox\" data-stimeo--bulk-select-target=\"item\">\n * <input type=\"checkbox\" data-stimeo--bulk-select-target=\"item\">\n * <div data-stimeo--bulk-select-target=\"bar\" hidden role=\"toolbar\"\n * data-controller=\"stimeo--toolbar\" aria-label=\"Bulk actions\">\n * <span data-stimeo--bulk-select-target=\"count\"></span> selected\n * <button data-stimeo--bulk-select-target=\"selectAllPages\"\n * data-stimeo--toolbar-target=\"control\"\n * data-action=\"click->stimeo--bulk-select#selectAllPages\">Select all</button>\n * <button data-stimeo--toolbar-target=\"control\"\n * data-action=\"click->stimeo--bulk-select#clear\">Clear</button>\n * </div>\n * </div>\n *\n * The bar carries `role=\"toolbar\"`, so it is composed with `stimeo--toolbar` to get\n * the arrow-key movement and single tab stop that role calls for.\n *\n * `change` and `reconcile` both dispatch `{ count, allPages }`.\n *\n * Selection is two-stage. The select-all box covers the rows on this page; the\n * optional `selectAllPages` control extends the selection to `totalCount` rows\n * across every page, checking each row here as it does so. Touching any single row\n * leaves that mode, because the selection is no longer the whole set.\n *\n * @remarks\n * Behavior only — it never runs the batch action (that is the consumer's\n * form/Turbo) nor fetches/pages rows. Selection lives **only** in each checkbox's\n * `checked` (no module-scope set), so `connect()` recomputes idempotently from the\n * DOM after a Turbo swap. Row `change` is handled by **delegation** on the\n * container, so dynamically-added rows work without per-row `data-action`, while\n * rows arriving or leaving on their own are picked up by the target callbacks and\n * coalesced into one repair per batch.\n *\n * `change` is dispatched when the user moves the selection; `reconcile` when the\n * controller repairs the figures itself — a row added or removed by the page, or a\n * render input changing at runtime. Both carry the same detail.\n *\n * The count reaches assistive tech through the page's shared `stimeo--announcer`,\n * worded by the consumer via `announceText` (`{count}` expands to the figure being\n * shown; an empty template stays silent, keeping announcements opt-in and\n * i18n-neutral).\n *\n * Showing the bar never steals focus (WCAG 2.2 2.4.3). Hiding it hands focus to\n * the select-all box first when the bar holds it, so the keyboard user keeps a Tab\n * position instead of falling back to the document.\n *\n * A non-finite `totalCount` falls back to the Value's default and is written back\n * to the attribute, so the count never renders as `NaN`.\n *\n * The delegated listener and the pending repair are both released on `disconnect()`.\n */\nexport class BulkSelectController extends Controller<HTMLElement> {\n static override targets = [\"all\", \"item\", \"bar\", \"count\", \"selectAllPages\"];\n static override values = {\n totalCount: { type: Number, default: 0 },\n announceText: { type: String, default: \"\" },\n };\n static actions = [\"clear\", \"selectAllPages\"] as const;\n static events = [\"change\", \"reconcile\"] as const;\n\n declare readonly allTarget: HTMLInputElement;\n declare readonly barTarget: HTMLElement;\n declare readonly countTarget: HTMLElement;\n declare readonly itemTargets: HTMLInputElement[];\n declare readonly hasAllTarget: boolean;\n declare readonly hasBarTarget: boolean;\n declare readonly hasCountTarget: boolean;\n\n declare totalCountValue: number;\n declare announceTextValue: string;\n\n /** All-pages mode is a transient UI state, mirrored to `data-all-pages` so a\n * `connect()` over markup that already carries the attribute rehydrates the\n * mode — a morph, a Turbo Stream, or a server that renders it back. A restore\n * visit serves the server's markup instead, so the mode does not survive one. */\n #allPagesMode = false;\n /** Last emitted figures, so a recompute reports only on a real change. */\n #lastCount = -1;\n #lastAllPages = false;\n\n /** Collapses every signal from one DOM or Value update into one repair. */\n readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileNow());\n\n /** Delegated `change` handler covering the select-all box and every row. */\n readonly #onChange = (event: Event): void => {\n const target = event.target as HTMLElement | null;\n if (!target) return;\n if (this.hasAllTarget && target === this.allTarget) {\n this.#applyAll();\n } else if (this.itemTargets.some((item) => item === target)) {\n this.#exitAllPages();\n this.#reportChange(this.#recompute());\n }\n };\n\n override connect(): void {\n this.#allPagesMode = this.element.dataset.allPages === \"true\";\n this.#reconcile.activate();\n this.element.addEventListener(\"change\", this.#onChange);\n this.#recompute();\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"change\", this.#onChange);\n this.#reconcile.cancel();\n }\n\n /** Repairs the figures for a row that arrived at runtime. */\n itemTargetConnected(): void {\n this.#reconcile.schedule();\n }\n\n /** Repairs the figures after a row leaves, so a removed selection stops counting. */\n itemTargetDisconnected(): void {\n this.#reconcile.schedule();\n }\n\n /** Reflects the current selection onto a select-all box added at runtime. */\n allTargetConnected(): void {\n this.#reconcile.schedule();\n }\n\n /** Repairs the figures after the select-all box leaves. */\n allTargetDisconnected(): void {\n this.#reconcile.schedule();\n }\n\n /** Repaints the count for a total that changed at runtime, rejecting non-finite ones. */\n totalCountValueChanged(): void {\n if (!Number.isFinite(this.totalCountValue)) {\n this.totalCountValue = 0;\n return;\n }\n this.#reconcile.schedule();\n }\n\n /** Repaints so wording changed at runtime is used by the next announcement. */\n announceTextValueChanged(): void {\n this.#reconcile.schedule();\n }\n\n /** Clears every selection (rows + select-all) and exits all-pages mode. */\n clear(): void {\n for (const item of this.itemTargets) item.checked = false;\n if (this.hasAllTarget) {\n this.allTarget.checked = false;\n this.allTarget.indeterminate = false;\n }\n this.#exitAllPages();\n this.#reportChange(this.#recompute());\n }\n\n /**\n * Enters \"select all across pages\" mode: the count shows `totalCount`, and every\n * row on this page is checked.\n *\n * The mode's claim is that the whole set is selected, so leaving a visible row\n * unchecked would put the page and the count in open disagreement.\n */\n selectAllPages(): void {\n this.#allPagesMode = true;\n this.#checkEveryRow();\n this.#reportChange(this.#recompute());\n }\n\n /** Marks every row on this page selected. */\n #checkEveryRow(): void {\n for (const item of this.itemTargets) item.checked = true;\n }\n\n /** Mirrors the select-all box to every row, then recomputes. */\n #applyAll(): void {\n if (!this.hasAllTarget) return;\n const { checked } = this.allTarget;\n for (const item of this.itemTargets) item.checked = checked;\n this.#exitAllPages();\n this.#reportChange(this.#recompute());\n }\n\n #exitAllPages(): void {\n this.#allPagesMode = false;\n }\n\n /** Repairs the derived state after the page moved rows or a render input. */\n #reconcileNow(): void {\n // A row that arrives while the whole set is selected is one of the rows the\n // mode already claims, so it lands checked instead of contradicting the count.\n if (this.#allPagesMode) this.#checkEveryRow();\n const detail = this.#recompute();\n if (!detail) return;\n this.dispatch(\"reconcile\", { detail });\n this.#announce(detail);\n }\n\n /** Reports a selection the user moved. */\n #reportChange(detail: SelectionDetail | null): void {\n if (!detail) return;\n this.dispatch(\"change\", { detail });\n this.#announce(detail);\n }\n\n /** Hands the count to the shared announcer, worded by the consumer. */\n #announce(detail: SelectionDetail): void {\n announce(fillTemplate(this.announceTextValue, { count: detail.count }));\n }\n\n /**\n * Recomputes the count, the select-all checked/indeterminate state, and the bar\n * visibility from the current DOM. Returns the figures when the emitted count or\n * all-pages flag actually moved, and `null` when they did not.\n *\n * @stimeoRenderRoot\n */\n #recompute(): SelectionDetail | null {\n const items = this.itemTargets;\n const total = items.length;\n const checked = items.filter((item) => item.checked).length;\n const allPages = this.#allPagesMode;\n\n if (this.hasAllTarget) {\n this.allTarget.checked = total > 0 && checked === total;\n this.allTarget.indeterminate = checked > 0 && checked < total;\n }\n\n const count = allPages ? this.totalCountValue : checked;\n const show = allPages || checked > 0;\n\n if (this.hasBarTarget) {\n // Hand the Tab position over before the bar leaves, or focus falls to the\n // document and the keyboard user loses their place.\n if (!show && this.hasAllTarget && this.barTarget.contains(document.activeElement)) {\n this.allTarget.focus();\n }\n this.barTarget.hidden = !show;\n }\n if (this.hasCountTarget) this.countTarget.textContent = String(count);\n\n this.element.setAttribute(\"data-selected-count\", String(checked));\n if (allPages) this.element.setAttribute(\"data-all-pages\", \"true\");\n else this.element.removeAttribute(\"data-all-pages\");\n\n const changed = count !== this.#lastCount || allPages !== this.#lastAllPages;\n this.#lastCount = count;\n this.#lastAllPages = allPages;\n return changed ? { count, allPages } : null;\n }\n}\n"]}
|
|
@@ -1,30 +1,52 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Headless copy-to-clipboard behavior with a
|
|
4
|
+
* Headless copy-to-clipboard behavior with a completion slot and a screen-reader
|
|
5
|
+
* announcement.
|
|
5
6
|
*
|
|
6
7
|
* Markup contract (identifier: `stimeo--clipboard`):
|
|
7
8
|
* <div data-controller="stimeo--clipboard"
|
|
8
|
-
* data-stimeo--clipboard-feedback-duration-value="2000"
|
|
9
|
+
* data-stimeo--clipboard-feedback-duration-value="2000"
|
|
10
|
+
* data-stimeo--clipboard-announce-copied-text-value="Copied to clipboard"
|
|
11
|
+
* data-stimeo--clipboard-announce-error-text-value="Copy failed">
|
|
9
12
|
* <input type="text" value="https://example.com" readonly
|
|
10
13
|
* data-stimeo--clipboard-target="source">
|
|
11
14
|
* <button type="button" data-stimeo--clipboard-target="button"
|
|
12
15
|
* data-action="stimeo--clipboard#copy">Copy</button>
|
|
13
|
-
* <span
|
|
14
|
-
* data-stimeo--clipboard-target="feedback"></span>
|
|
16
|
+
* <span data-stimeo--clipboard-target="feedback"></span>
|
|
15
17
|
* </div>
|
|
16
18
|
*
|
|
17
|
-
* No dedicated APG pattern; this follows the Button
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* No dedicated APG pattern; this follows the Button practice plus a status message
|
|
20
|
+
* ({@link https://www.w3.org/WAI/WCAG22/Understanding/status-changes.html | WCAG 2.2 SC 4.1.3}).
|
|
21
|
+
* The copy uses the standard `navigator.clipboard` API (no extra dependency); every
|
|
22
|
+
* failure mode — an API the browser withholds outside a secure context as much as a
|
|
23
|
+
* rejected permission — settles the same way, as `data-state="error"` plus the
|
|
24
|
+
* `copy` event's `success: false`.
|
|
25
|
+
*
|
|
26
|
+
* Targets: `source` (the text to copy, when `text` is not set), `button` (the
|
|
27
|
+
* control that triggers `copy`), `feedback` (the **visible** completion slot).
|
|
28
|
+
*
|
|
29
|
+
* Values: `text` (copy this instead of reading `source`), `feedbackDuration` (ms the
|
|
30
|
+
* completion state is held; `0` or less arms no timer, so it stands until the next
|
|
31
|
+
* copy — a reconnect and the before-cache rewind still clear it), `copiedLabel` /
|
|
32
|
+
* `errorLabel` (what the visible slot shows), `announceCopiedText` /
|
|
33
|
+
* `announceErrorText` (what assistive tech hears; empty announces nothing).
|
|
22
34
|
*
|
|
23
35
|
* @remarks
|
|
24
|
-
* Behavior only — icon swaps and styling are the consumer's, keyed off
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
36
|
+
* Behavior only — icon swaps and styling are the consumer's, keyed off `data-state`
|
|
37
|
+
* (`idle` / `copied` / `error`).
|
|
38
|
+
*
|
|
39
|
+
* **The `feedback` slot must not carry live-region semantics.** Announcing is the
|
|
40
|
+
* page's shared `stimeo--announcer` job: it is seated before the change it reads,
|
|
41
|
+
* which a slot filled on demand cannot be, and it already collapses a repeat of the
|
|
42
|
+
* same wording so a second copy is still heard. A `role="status"` on the slot as
|
|
43
|
+
* well would say everything twice.
|
|
44
|
+
*
|
|
45
|
+
* `copied` and `error` are transient: the timer that clears them belongs to one
|
|
46
|
+
* connection, so a fresh `connect()` that finds either — a restored snapshot, an
|
|
47
|
+
* in-page move — returns to `idle`, and the state is rewound before Turbo caches
|
|
48
|
+
* the page ({@link BeforeCacheReset}). The rewind is silent: it discards nothing a
|
|
49
|
+
* reconnect does not derive again.
|
|
28
50
|
*/
|
|
29
51
|
declare class ClipboardController extends Controller<HTMLElement> {
|
|
30
52
|
#private;
|
|
@@ -46,6 +68,14 @@ declare class ClipboardController extends Controller<HTMLElement> {
|
|
|
46
68
|
type: StringConstructor;
|
|
47
69
|
default: string;
|
|
48
70
|
};
|
|
71
|
+
announceCopiedText: {
|
|
72
|
+
type: StringConstructor;
|
|
73
|
+
default: string;
|
|
74
|
+
};
|
|
75
|
+
announceErrorText: {
|
|
76
|
+
type: StringConstructor;
|
|
77
|
+
default: string;
|
|
78
|
+
};
|
|
49
79
|
};
|
|
50
80
|
static actions: readonly ["copy"];
|
|
51
81
|
static events: readonly ["copy"];
|
|
@@ -59,12 +89,15 @@ declare class ClipboardController extends Controller<HTMLElement> {
|
|
|
59
89
|
feedbackDurationValue: number;
|
|
60
90
|
copiedLabelValue: string;
|
|
61
91
|
errorLabelValue: string;
|
|
92
|
+
announceCopiedTextValue: string;
|
|
93
|
+
announceErrorTextValue: string;
|
|
62
94
|
connect(): void;
|
|
63
95
|
disconnect(): void;
|
|
64
96
|
/**
|
|
65
97
|
* Copies the resolved text and reports the outcome. Bound via `data-action`
|
|
66
|
-
* (click).
|
|
67
|
-
* — including on failure — so consumers can react either way.
|
|
98
|
+
* (click). Dispatches `stimeo--clipboard:copy` with `{ success, text }` once per
|
|
99
|
+
* completed attempt — including on failure — so consumers can react either way.
|
|
100
|
+
* An attempt whose connection ended while it was in flight reports nothing.
|
|
68
101
|
*/
|
|
69
102
|
copy(): Promise<void>;
|
|
70
103
|
}
|