text-slicer 2.0.0 → 2.1.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # text-slicer
2
2
 
3
- Split text inside an HTML element into words and/or characters, wrapping each fragment in its own span.
3
+ Create word and grapheme-level DOM hooks from plain text for animation and styling.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/text-slicer.svg?colorB=brightgreen)](https://www.npmjs.com/package/text-slicer)
6
6
  [![NPM Downloads](https://img.shields.io/npm/dm/text-slicer.svg?style=flat)](https://www.npmjs.com/package/text-slicer)
@@ -9,12 +9,14 @@ Split text inside an HTML element into words and/or characters, wrapping each fr
9
9
 
10
10
  ---
11
11
 
12
- - Split by words, characters, or both (`splitMode`).
13
- - Optional CSS variables (`--word-index`, `--char-index`, `--word-total`, `--char-total`, `--container-height`).
14
- - Optional `data-word` / `data-char` attributes for styling or scripting.
15
- - Lifecycle helpers: `init`, `reinit`, `clear`, `split`, `destroy`, `updateOptions`, `lockHeight`, `unlockHeight`.
16
- - `onAfterRender` callback with word/char totals and timestamp.
17
- - ~1.5kB gzipped.
12
+ ## Features
13
+
14
+ - Split short plain text into words, graphemes, or both.
15
+ - Use locale-aware word and grapheme segmentation with `Intl.Segmenter`.
16
+ - Add CSS hooks through `--word-index`, `--char-index`, `--word-total`, and `--char-total`.
17
+ - Add optional `data-word` and `data-char` attributes when scripting needs the raw text.
18
+ - Clean up with lifecycle methods such as `destroy()`, `reinit()`, `lockHeight()`, and `unlockHeight()`.
19
+ - Read render metrics with `onAfterRender`.
18
20
 
19
21
  ---
20
22
 
@@ -24,6 +26,8 @@ Split text inside an HTML element into words and/or characters, wrapping each fr
24
26
  npm install text-slicer
25
27
  ```
26
28
 
29
+ ---
30
+
27
31
  ## Quick Start
28
32
 
29
33
  HTML:
@@ -66,68 +70,198 @@ Apply to every matching element on the page:
66
70
 
67
71
  ```ts
68
72
  document.addEventListener('DOMContentLoaded', () => {
69
- document.querySelectorAll('.text-slicer').forEach((element) => {
70
- const textSlicer = new TextSlicer({ container: element });
73
+ const textSlicers = Array.from(
74
+ document.querySelectorAll<HTMLElement>('.text-slicer'),
75
+ (container) => {
76
+ const textSlicer = new TextSlicer({ container });
71
77
 
72
- textSlicer.init();
73
- });
78
+ textSlicer.init();
79
+
80
+ return textSlicer;
81
+ },
82
+ );
83
+
84
+ // Later, before removing the elements, replacing this section,
85
+ // or cleaning up during SPA route changes:
86
+ const destroyTextSlicers = () => {
87
+ textSlicers.forEach((textSlicer) => textSlicer.destroy());
88
+ };
74
89
  });
75
90
  ```
76
91
 
77
- ## API
92
+ ---
78
93
 
79
- Named exports:
94
+ ## API
80
95
 
81
96
  ```ts
82
97
  import { TextSlicer, CLASSNAMES } from 'text-slicer';
83
98
  ```
84
99
 
85
- - `TextSlicer` — main class.
86
- - `CLASSNAMES` — BEM-style class map (`word`, `char`, `whitespace`).
100
+ - `TextSlicer` — the main class.
101
+ - `CLASSNAMES` — class map for `word`, `char`, and `whitespace`.
87
102
  - Types: `SplitMode`, `TextSlicerOptions`, `TextSlicerMetrics`, `TextSlicerCallbacks`.
88
103
 
89
- Constructor: `new TextSlicer(options?, callbacks?)`.
104
+ Constructor:
105
+
106
+ ```ts
107
+ new TextSlicer(options?, callbacks?);
108
+ ```
109
+
110
+ ---
90
111
 
91
112
  ## Options
92
113
 
93
- | Option | Type | Default | Description |
94
- |:----------------------|:------------------------|:-----------:|:----------------------------------------------------------------------------|
95
- | `container` | `HTMLElement \| string` | `undefined` | Target element or selector for text splitting. |
96
- | `splitMode` | `'words' \| 'chars' \| 'both'` | `both` | Split by words, characters, or both. |
97
- | `cssVariables` | `boolean` | `false` | Sets `--word-index`, `--char-index`, `--word-total`, `--char-total` on spans. |
98
- | `dataAttributes` | `boolean` | `false` | Adds `data-word` and `data-char` attributes. |
99
- | `keepWhitespaceNodes` | `boolean` | `true` | When `false`, whitespace nodes are ignored in character mode. |
100
- | `containerHeightVar` | `boolean` | `false` | Sets dynamic `--container-height` via `ResizeObserver`. |
114
+ | Option | Type | Default | Description |
115
+ |:--|:--|:--:|:--|
116
+ | `container` | `HTMLElement \| string` | `undefined` | Target element or selector. One `TextSlicer` instance manages one element. |
117
+ | `splitMode` | `'words' \| 'chars' \| 'both'` | `both` | Split into words, graphemes, or both. |
118
+ | `cssVariables` | `boolean` | `false` | Sets `--word-index` / `--char-index` on generated spans and `--word-total` / `--char-total` on the container. |
119
+ | `dataAttributes` | `boolean` | `false` | Adds `data-word` and `data-char` attributes. |
120
+ | `keepWhitespaceNodes` | `boolean` | `true` | When `false`, whitespace stays visible as text nodes instead of `.ts-whitespace` spans in character-based modes. |
121
+ | `containerHeightVar` | `boolean` | `false` | Sets `--container-height` from `ResizeObserver` measurements when available. |
122
+ | `locale` | `string \| string[]` | `undefined` | Locale passed to `Intl.Segmenter`; omitted means the runtime default locale. |
123
+
124
+ ---
101
125
 
102
126
  ## Callbacks
103
127
 
104
- | Callback | Arguments | Description |
105
- |-----------------|---------------------|--------------------------------------------------------------------------|
106
- | `onAfterRender` | `TextSlicerMetrics` | Called after render; provides `wordTotal`, `charTotal`, `renderedAt`. |
128
+ | Callback | Arguments | Description |
129
+ |:--|:--|:--|
130
+ | `onAfterRender` | `TextSlicerMetrics` | Runs after render with `wordTotal`, `charTotal`, and `renderedAt`. |
131
+
132
+ `charTotal` counts non-whitespace grapheme clusters, not UTF-16 code units. In `chars` and `both` modes it matches rendered `.ts-char` spans. In `words` mode, metrics still describe the original plain text even though character spans are not rendered.
133
+
134
+ ---
107
135
 
108
136
  ## Methods
109
137
 
110
- | Method | Description |
111
- |--------------------------------|----------------------------------------------------------|
112
- | `init()` | Initializes and renders text splitting. |
113
- | `reinit(newText?, options?)` | Re-initializes with optional new text and updated options. |
114
- | `clear()` | Clears all content inside the container. |
115
- | `split()` | Manually triggers splitting and rendering. |
116
- | `destroy()` | Cleans up instance, observers, and styles. |
117
- | `updateOptions(options)` | Updates options at runtime; re-renders if mounted. |
118
- | `lockHeight()` | Locks container height to its measured value. |
119
- | `unlockHeight()` | Unlocks container height. |
120
- | `metrics` (getter) | Returns `wordTotal`, `charTotal`, and `renderedAt`. |
138
+ | Method | Description |
139
+ |:--|:--|
140
+ | `init()` | Render the current text and mark the instance mounted. |
141
+ | `reinit(newText?, options?)` | Render again, optionally with new plain text and updated options. |
142
+ | `clear()` | Remove rendered children and restore library-owned accessibility attributes. Use `destroy()` for full cleanup. |
143
+ | `split()` | Render the current text again. |
144
+ | `destroy()` | Disconnect observers, restore library-owned styles and attributes, remove generated spans, and put the original plain text back. |
145
+ | `updateOptions(options)` | Update options and re-render if the instance is mounted. |
146
+ | `lockHeight()` | Lock the container height to its measured value. |
147
+ | `unlockHeight()` | Restore the previous inline `height`. |
148
+ | `metrics` | Return metrics computed from the original plain text. |
149
+
150
+ ---
151
+
152
+ ## Splitting Semantics
153
+
154
+ - `words`: word-like segments become `.ts-word`; punctuation stays visible but is not counted as a word.
155
+ - `chars`: non-whitespace grapheme clusters become `.ts-char`.
156
+ - `both`: word-like segments become `.ts-word`, and every non-whitespace grapheme, including punctuation, becomes `.ts-char`.
157
+
158
+ Word-like segments come from `Intl.Segmenter` with `granularity: 'word'`. Whitespace stays visible as `.ts-whitespace` spans, or as plain text nodes when `keepWhitespaceNodes: false` is used in character-based modes.
159
+
160
+ ---
161
+
162
+ ## Limitations
163
+
164
+ - Plain text only: nested markup is not preserved. `destroy()` restores `textContent`, not the original child nodes.
165
+ - Best for short headings, labels, and animation copy. Long text can create a lot of DOM nodes.
166
+ - `split()` and `updateOptions()` rebuild the generated content.
167
+ - `Intl.Segmenter` is used when available. The fallback is simpler and may miss locale-specific word boundaries or complex emoji clusters.
168
+ - Client-only: call `init()` after mount in SSR frameworks.
169
+
170
+ ---
171
+
172
+ ## Accessibility
173
+
174
+ Generated visual spans are marked `aria-hidden="true"`.
175
+
176
+ If the container has no `aria-label` or `aria-labelledby`, TextSlicer adds a managed `aria-label` with the original plain text and restores it on cleanup.
177
+
178
+ If you provide your own `aria-label` or `aria-labelledby`, TextSlicer leaves it alone. Keep your custom accessible label in sync when calling `reinit()` with new text.
179
+
180
+ ---
181
+
182
+ ## Framework Integration
183
+
184
+ Call `init()` only after the element exists in the browser. Call `destroy()` before unmounting or replacing the element.
185
+
186
+ React / Next.js:
187
+
188
+ ```tsx
189
+ useEffect(() => {
190
+ const slicer = new TextSlicer({
191
+ container: ref.current,
192
+ splitMode: 'chars',
193
+ cssVariables: true,
194
+ });
195
+
196
+ slicer.init();
197
+
198
+ return () => slicer.destroy();
199
+ }, []);
200
+ ```
201
+
202
+ Vue:
203
+
204
+ ```ts
205
+ onMounted(() => {
206
+ slicer = new TextSlicer({ container: el.value });
207
+ slicer.init();
208
+ });
209
+
210
+ onBeforeUnmount(() => slicer?.destroy());
211
+ ```
212
+
213
+ Svelte:
214
+
215
+ ```ts
216
+ onMount(() => {
217
+ const slicer = new TextSlicer({ container: node });
218
+ slicer.init();
219
+
220
+ return () => slicer.destroy();
221
+ });
222
+ ```
223
+
224
+ ---
121
225
 
122
226
  ## Styling
123
227
 
124
228
  Exported `CLASSNAMES`:
125
229
 
126
- - `ts-word` — word wrapper (when splitting includes words).
230
+ - `ts-word` — word wrapper.
127
231
  - `ts-char` — character span.
128
232
  - `ts-whitespace` — whitespace span.
129
233
 
130
- When `cssVariables: true`, spans receive `--word-index`, `--char-index`; the container may receive `--word-total`, `--char-total`, and (with `containerHeightVar`) `--container-height`.
234
+ When `cssVariables: true`, `--word-total` and `--char-total` are set on the container. `--word-index` is set on `.ts-word` spans, and `--char-index` is set on non-whitespace `.ts-char` spans.
235
+
236
+ `--container-height` is based on `ResizeObserver` measurements when `containerHeightVar: true` and the platform API is available.
237
+
238
+ Staggered character reveal:
239
+
240
+ ```css
241
+ .ts-char {
242
+ opacity: 0;
243
+ transform: translateY(0.5em);
244
+ animation: char-in 0.4s ease forwards;
245
+ animation-delay: calc(var(--char-index) * 40ms);
246
+ }
247
+
248
+ @keyframes char-in {
249
+ to {
250
+ opacity: 1;
251
+ transform: translateY(0);
252
+ }
253
+ }
254
+ ```
255
+
256
+ ---
257
+
258
+ ## Browser Support
259
+
260
+ `Intl.Segmenter` is required for correct word and grapheme splitting. Without it, TextSlicer uses a simplified fallback; emoji and complex grapheme clusters may be incorrect.
261
+
262
+ `containerHeightVar` is a no-op when `ResizeObserver` is unavailable.
263
+
264
+ ---
131
265
 
132
266
  ## License
133
267
 
package/dist/index.cjs CHANGED
@@ -6,14 +6,14 @@ const DEFAULT_OPTIONS = {
6
6
  cssVariables: false,
7
7
  dataAttributes: false,
8
8
  keepWhitespaceNodes: true,
9
- containerHeightVar: false
9
+ containerHeightVar: false,
10
+ locale: void 0
10
11
  };
11
12
  const CLASSNAMES = Object.freeze({
12
13
  word: "ts-word",
13
14
  char: "ts-char",
14
15
  whitespace: "ts-whitespace"
15
16
  });
16
- const SPACE = " ";
17
17
  const CSS_VAR_WORD_TOTAL = "--word-total";
18
18
  const CSS_VAR_CHAR_TOTAL = "--char-total";
19
19
  const CSS_VAR_WORD_INDEX = "--word-index";
@@ -26,15 +26,58 @@ const resolveContainer = (container) => {
26
26
  if (!container) return null;
27
27
  return typeof container === "string" ? document.querySelector(container) : container;
28
28
  };
29
- const splitIntoGraphemes = (text) => {
29
+ const localeKey = (locale) => locale === void 0 ? "" : Array.isArray(locale) ? locale.join("\0") : locale;
30
+ const segmenterCache = /* @__PURE__ */ new Map();
31
+ const getSegmenter = (locale, granularity) => {
32
+ if (typeof Intl === "undefined") return null;
30
33
  const Seg = Intl.Segmenter;
31
- if (typeof Seg === "function") {
32
- const segmenter = new Seg("en", { granularity: "grapheme" });
33
- return Array.from(segmenter.segment(text), (s) => s.segment);
34
+ if (typeof Seg !== "function") return null;
35
+ const key = `${granularity}:${localeKey(locale)}`;
36
+ let segmenter = segmenterCache.get(key);
37
+ if (!segmenter) {
38
+ segmenter = new Seg(locale, { granularity });
39
+ segmenterCache.set(key, segmenter);
34
40
  }
41
+ return segmenter;
42
+ };
43
+ const splitIntoGraphemes = (text, locale) => {
44
+ const segmenter = getSegmenter(locale, "grapheme");
45
+ if (segmenter) return Array.from(segmenter.segment(text), (s) => s.segment);
35
46
  return Array.from(text);
36
47
  };
37
- const splitIntoWords = (text) => text.split(SPACE);
48
+ const isWhitespaceGrapheme = (ch) => /^\s+$/u.test(ch);
49
+ const countCharGraphemes = (text, locale) => splitIntoGraphemes(text, locale).filter((ch) => !isWhitespaceGrapheme(ch)).length;
50
+ const segmentWords = (text, locale) => {
51
+ const segmenter = getSegmenter(locale, "word");
52
+ if (segmenter) return Array.from(segmenter.segment(text));
53
+ const words = text.split(/\s+/u).filter(Boolean);
54
+ const segments = [];
55
+ let cursor = 0;
56
+ for (const word of words) {
57
+ const start = text.indexOf(word, cursor);
58
+ if (start > cursor) segments.push({
59
+ segment: text.slice(cursor, start),
60
+ index: cursor,
61
+ input: text,
62
+ isWordLike: false
63
+ });
64
+ segments.push({
65
+ segment: word,
66
+ index: start,
67
+ input: text,
68
+ isWordLike: true
69
+ });
70
+ cursor = start + word.length;
71
+ }
72
+ if (cursor < text.length) segments.push({
73
+ segment: text.slice(cursor),
74
+ index: cursor,
75
+ input: text,
76
+ isWordLike: false
77
+ });
78
+ return segments;
79
+ };
80
+ const countWords = (text, locale) => segmentWords(text, locale).filter((s) => s.isWordLike).length;
38
81
  const emptyElement = (el) => {
39
82
  el.replaceChildren();
40
83
  };
@@ -47,6 +90,13 @@ const omitUndefined = (obj) => {
47
90
  });
48
91
  return out;
49
92
  };
93
+ const mergeRuntimeOptions = (base, patch) => {
94
+ const { container: _container, ...runtimeOptions } = patch;
95
+ return {
96
+ ...base,
97
+ ...omitUndefined(runtimeOptions)
98
+ };
99
+ };
50
100
  var TextSlicer = class {
51
101
  el;
52
102
  original;
@@ -55,23 +105,24 @@ var TextSlicer = class {
55
105
  charIndex;
56
106
  mounted;
57
107
  resizeObserver;
108
+ managedStyles = /* @__PURE__ */ new Map();
109
+ managedAttributes = /* @__PURE__ */ new Map();
58
110
  constructor(options = {}, callbacks) {
59
- const el = resolveContainer(options.container);
111
+ const { container, ...runtimeOptions } = options;
112
+ const el = resolveContainer(container);
60
113
  this.el = el;
61
114
  this.original = isHTMLElement(el) ? el.textContent?.toString() ?? "" : "";
62
- this.opts = {
63
- ...DEFAULT_OPTIONS,
64
- ...omitUndefined(options)
65
- };
115
+ this.opts = mergeRuntimeOptions(DEFAULT_OPTIONS, runtimeOptions);
66
116
  this.callbacks = callbacks;
67
117
  this.charIndex = 0;
68
118
  this.mounted = false;
69
119
  }
70
120
  get metrics() {
71
121
  const text = this.original;
122
+ const { locale } = this.opts;
72
123
  return {
73
- wordTotal: text.length ? splitIntoWords(text).length : 0,
74
- charTotal: text.length,
124
+ wordTotal: text.length ? countWords(text, locale) : 0,
125
+ charTotal: text.length ? countCharGraphemes(text, locale) : 0,
75
126
  renderedAt: Date.now()
76
127
  };
77
128
  }
@@ -79,130 +130,197 @@ var TextSlicer = class {
79
130
  if (!this.el) return;
80
131
  this.mounted = true;
81
132
  this.split();
82
- if (this.opts.containerHeightVar) this.initHeightObserver();
133
+ this.syncHeightObserver();
83
134
  }
84
135
  reinit(newText, next) {
85
136
  if (!this.el) return;
86
137
  if (typeof newText === "string") this.original = newText;
87
- if (next) this.opts = {
88
- ...this.opts,
89
- ...omitUndefined(next)
90
- };
138
+ if (next) this.opts = mergeRuntimeOptions(this.opts, next);
139
+ this.mounted = true;
91
140
  this.split();
141
+ this.syncHeightObserver();
92
142
  }
93
143
  clear() {
94
144
  if (!this.el) return;
95
145
  emptyElement(this.el);
146
+ this.restoreManagedAttribute("aria-label");
96
147
  }
97
148
  split() {
98
149
  if (!this.el) return;
99
- this.clear();
150
+ emptyElement(this.el);
100
151
  this.charIndex = 0;
101
152
  const text = this.original;
102
153
  const fragment = document.createDocumentFragment();
103
- const words = splitIntoWords(text);
104
154
  if (this.opts.splitMode === "chars") this.appendChars(fragment, text);
105
- else this.appendWords(fragment, words);
155
+ else this.appendWords(fragment, text);
106
156
  this.el.appendChild(fragment);
157
+ this.syncAccessibleLabel(text);
158
+ const metrics = this.metrics;
107
159
  if (this.opts.cssVariables) {
108
- this.el.style.setProperty(CSS_VAR_WORD_TOTAL, String(words.length));
109
- this.el.style.setProperty(CSS_VAR_CHAR_TOTAL, String(text.length));
160
+ this.setManagedStyle(CSS_VAR_WORD_TOTAL, String(metrics.wordTotal));
161
+ this.setManagedStyle(CSS_VAR_CHAR_TOTAL, String(metrics.charTotal));
162
+ } else {
163
+ this.restoreManagedStyle(CSS_VAR_WORD_TOTAL);
164
+ this.restoreManagedStyle(CSS_VAR_CHAR_TOTAL);
110
165
  }
111
- this.callbacks?.onAfterRender?.(this.metrics);
166
+ this.callbacks?.onAfterRender?.(metrics);
112
167
  }
113
168
  destroy() {
114
169
  if (!this.el) return;
115
- this.clear();
116
- this.unlockHeight();
117
- if (this.resizeObserver) {
118
- this.resizeObserver.disconnect();
119
- this.resizeObserver = void 0;
120
- }
121
- if (this.opts.containerHeightVar) this.el.style.removeProperty(CSS_VAR_CONTAINER_HEIGHT);
170
+ this.syncHeightObserver(false);
171
+ this.restoreManagedStyles();
172
+ this.restoreManagedAttribute("aria-label");
173
+ this.el.textContent = this.original;
122
174
  this.mounted = false;
123
175
  }
124
176
  updateOptions(next) {
125
- this.opts = {
126
- ...this.opts,
127
- ...omitUndefined(next)
128
- };
129
- if (this.mounted) this.split();
177
+ this.opts = mergeRuntimeOptions(this.opts, next);
178
+ if (this.mounted) {
179
+ this.split();
180
+ this.syncHeightObserver();
181
+ }
130
182
  }
131
183
  lockHeight() {
132
184
  if (!this.el) return;
133
185
  const h = this.measureHeight();
134
- if (h > 0) this.el.style.height = `${h}px`;
186
+ if (h > 0) this.setManagedStyle("height", `${h}px`);
135
187
  }
136
188
  unlockHeight() {
137
- if (!this.el) return;
138
- this.el.style.removeProperty("height");
139
- }
140
- appendWords(fragment, words) {
141
- words.forEach((word, wordIndex) => {
142
- if (this.opts.splitMode === "both") {
143
- const wordSpan = this.createWordSpan(wordIndex, word);
144
- for (const ch of splitIntoGraphemes(word)) {
145
- const charSpan = this.createCharSpan(ch);
146
- wordSpan.append(charSpan);
147
- }
148
- fragment.append(wordSpan);
149
- } else {
150
- const wordSpan = this.createWordSpan(wordIndex);
151
- wordSpan.append(document.createTextNode(word));
152
- fragment.append(wordSpan);
153
- }
154
- if (wordIndex < words.length - 1) fragment.append(this.createSpaceSpan());
155
- });
189
+ this.restoreManagedStyle("height");
190
+ }
191
+ appendWords(fragment, text) {
192
+ const segments = segmentWords(text, this.opts.locale);
193
+ let wordIndex = 0;
194
+ for (const { segment, isWordLike } of segments) if (isWordLike) {
195
+ const wordSpan = this.createWordSpan(wordIndex, segment);
196
+ if (this.opts.splitMode === "both") this.appendGraphemes(wordSpan, segment);
197
+ else wordSpan.append(document.createTextNode(segment));
198
+ fragment.append(wordSpan);
199
+ wordIndex += 1;
200
+ } else if (isWhitespaceGrapheme(segment)) if (this.opts.splitMode === "both") this.appendGraphemes(fragment, segment);
201
+ else fragment.append(this.createWhitespaceSpan(segment));
202
+ else if (segment.length > 0) if (this.opts.splitMode === "both") this.appendGraphemes(fragment, segment);
203
+ else fragment.append(this.createHiddenTextSpan(segment));
156
204
  }
157
205
  appendChars(fragment, text) {
158
- for (const ch of splitIntoGraphemes(text)) {
159
- const span = this.createCharSpan(ch);
160
- fragment.append(span);
206
+ for (const ch of splitIntoGraphemes(text, this.opts.locale)) this.appendGrapheme(fragment, ch);
207
+ }
208
+ appendGraphemes(parent, text) {
209
+ for (const ch of splitIntoGraphemes(text, this.opts.locale)) this.appendGrapheme(parent, ch);
210
+ }
211
+ appendGrapheme(parent, ch) {
212
+ if (isWhitespaceGrapheme(ch)) {
213
+ if (this.opts.keepWhitespaceNodes) parent.append(this.createWhitespaceSpan(ch));
214
+ else parent.append(document.createTextNode(ch));
215
+ return;
161
216
  }
217
+ parent.append(this.createCharSpan(ch));
162
218
  }
163
- createWordSpan(index, word = "") {
219
+ createWordSpan(index, word) {
164
220
  const span = document.createElement("span");
165
221
  span.classList.add(CLASSNAMES.word);
166
- if (this.opts.dataAttributes && word) span.setAttribute("data-word", word);
222
+ span.setAttribute("aria-hidden", "true");
223
+ if (this.opts.dataAttributes) span.setAttribute("data-word", word);
167
224
  if (this.opts.cssVariables) span.style.setProperty(CSS_VAR_WORD_INDEX, String(index));
168
225
  return span;
169
226
  }
170
227
  createCharSpan(ch) {
171
228
  const span = document.createElement("span");
172
229
  span.textContent = ch;
230
+ span.setAttribute("aria-hidden", "true");
231
+ span.classList.add(CLASSNAMES.char);
173
232
  if (this.opts.dataAttributes) span.setAttribute("data-char", ch);
174
- if (ch === SPACE) {
175
- span.classList.add(CLASSNAMES.whitespace);
176
- if (!this.opts.keepWhitespaceNodes) span.textContent = SPACE;
177
- } else {
178
- span.classList.add(CLASSNAMES.char);
179
- if (this.opts.cssVariables) span.style.setProperty(CSS_VAR_CHAR_INDEX, String(this.charIndex));
180
- this.charIndex += 1;
181
- }
233
+ if (this.opts.cssVariables) span.style.setProperty(CSS_VAR_CHAR_INDEX, String(this.charIndex));
234
+ this.charIndex += 1;
182
235
  return span;
183
236
  }
184
- createSpaceSpan() {
237
+ createWhitespaceSpan(text) {
185
238
  const span = document.createElement("span");
186
239
  span.classList.add(CLASSNAMES.whitespace);
187
- span.textContent = SPACE;
240
+ span.textContent = text;
241
+ span.setAttribute("aria-hidden", "true");
242
+ return span;
243
+ }
244
+ createHiddenTextSpan(text) {
245
+ const span = document.createElement("span");
246
+ span.textContent = text;
247
+ span.setAttribute("aria-hidden", "true");
188
248
  return span;
189
249
  }
250
+ syncAccessibleLabel(text) {
251
+ if (!this.el) return;
252
+ if (this.managedAttributes.has("aria-label")) {
253
+ this.setManagedAttribute("aria-label", text);
254
+ return;
255
+ }
256
+ if (this.el.hasAttribute("aria-label") || this.el.hasAttribute("aria-labelledby")) return;
257
+ this.setManagedAttribute("aria-label", text);
258
+ }
190
259
  measureHeight() {
191
260
  if (!this.el) return 0;
192
261
  this.el.classList.add(MEASURING_CLASS);
193
- this.el.offsetHeight;
194
- let h = this.el.offsetHeight || this.el.clientHeight || 0;
195
- if (!h) h = Math.round(this.el.getBoundingClientRect().height);
196
- this.el.classList.remove(MEASURING_CLASS);
197
- return Math.max(0, Math.ceil(h));
262
+ try {
263
+ this.el.offsetHeight;
264
+ let h = this.el.offsetHeight || this.el.clientHeight || 0;
265
+ if (!h) h = Math.round(this.el.getBoundingClientRect().height);
266
+ return Math.max(0, Math.ceil(h));
267
+ } finally {
268
+ this.el.classList.remove(MEASURING_CLASS);
269
+ }
270
+ }
271
+ syncHeightObserver(enable = this.opts.containerHeightVar) {
272
+ if (!this.el) return;
273
+ if (enable) {
274
+ if (typeof ResizeObserver !== "function") {
275
+ this.restoreManagedStyle(CSS_VAR_CONTAINER_HEIGHT);
276
+ return;
277
+ }
278
+ if (!this.resizeObserver) {
279
+ this.resizeObserver = new ResizeObserver((entries) => {
280
+ const entry = entries[0];
281
+ if (!entry || !this.el) return;
282
+ const boxSize = entry.borderBoxSize?.[0]?.blockSize;
283
+ const h = Math.max(0, Math.ceil(boxSize ?? entry.contentRect.height));
284
+ if (h > 0) this.setManagedStyle(CSS_VAR_CONTAINER_HEIGHT, `${h}px`);
285
+ });
286
+ this.resizeObserver.observe(this.el);
287
+ }
288
+ } else if (this.resizeObserver) {
289
+ this.resizeObserver.disconnect();
290
+ this.resizeObserver = void 0;
291
+ this.restoreManagedStyle(CSS_VAR_CONTAINER_HEIGHT);
292
+ }
198
293
  }
199
- initHeightObserver() {
294
+ setManagedStyle(property, value) {
200
295
  if (!this.el) return;
201
- this.resizeObserver = new ResizeObserver(() => {
202
- const h = this.measureHeight();
203
- if (h > 0) this.el?.style.setProperty(CSS_VAR_CONTAINER_HEIGHT, `${h}px`);
296
+ if (!this.managedStyles.has(property)) this.managedStyles.set(property, {
297
+ value: this.el.style.getPropertyValue(property),
298
+ priority: this.el.style.getPropertyPriority(property)
204
299
  });
205
- this.resizeObserver.observe(this.el);
300
+ this.el.style.setProperty(property, value);
301
+ }
302
+ restoreManagedStyle(property) {
303
+ if (!this.el) return;
304
+ const previous = this.managedStyles.get(property);
305
+ if (previous === void 0) return;
306
+ if (previous.value) this.el.style.setProperty(property, previous.value, previous.priority);
307
+ else this.el.style.removeProperty(property);
308
+ this.managedStyles.delete(property);
309
+ }
310
+ restoreManagedStyles() {
311
+ for (const property of [...this.managedStyles.keys()]) this.restoreManagedStyle(property);
312
+ }
313
+ setManagedAttribute(name, value) {
314
+ if (!this.el) return;
315
+ if (!this.managedAttributes.has(name)) this.managedAttributes.set(name, this.el.getAttribute(name));
316
+ this.el.setAttribute(name, value);
317
+ }
318
+ restoreManagedAttribute(name) {
319
+ if (!this.el || !this.managedAttributes.has(name)) return;
320
+ const previous = this.managedAttributes.get(name);
321
+ if (previous === null) this.el.removeAttribute(name);
322
+ else if (previous !== void 0) this.el.setAttribute(name, previous);
323
+ this.managedAttributes.delete(name);
206
324
  }
207
325
  };
208
326