@frontfactory/readmore 1.3.1 → 1.5.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
@@ -7,7 +7,7 @@
7
7
  Lightweight, framework-agnostic plugin to clamp text to N lines (or a fixed pixel height) with an ellipsis and a
8
8
  `Read more` / `Read less` toggle button.
9
9
 
10
- - Zero dependencies, ~1.3 KB gzipped
10
+ - Zero dependencies, ~1.9 KB gzipped
11
11
  - TypeScript types included
12
12
  - CSS-driven clamping via `-webkit-line-clamp` or `max-height`
13
13
  - The toggle button is only created when the text actually overflows
@@ -19,8 +19,23 @@ Lightweight, framework-agnostic plugin to clamp text to N lines (or a fixed pixe
19
19
  npm install @frontfactory/readmore
20
20
  ```
21
21
 
22
- Requires Node.js `>=20.19` to build from source. The published package runs in any modern browser supporting
23
- `ResizeObserver`.
22
+ Requires Node.js `^22.22.2 || >=24.22` to build from source. The published package is shipped as untranspiled ES2022
23
+ (private class members, logical assignment, `ResizeObserver`) and runs in Chrome/Edge 85+, Firefox 90+ and Safari 15+.
24
+
25
+ ### From a CDN
26
+
27
+ Without a bundler, load the stylesheet and the IIFE build, which exposes `FrontFactory.ReadMore` (an existing
28
+ `FrontFactory` global is extended, not replaced). Pin the major version in production:
29
+
30
+ ```html
31
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@frontfactory/readmore@1/dist/readmore.css">
32
+ <script src="https://cdn.jsdelivr.net/npm/@frontfactory/readmore@1/dist/readmore.iife.js"></script>
33
+ <script>
34
+ FrontFactory.ReadMore.init('.excerpt', {
35
+ lines: 3
36
+ });
37
+ </script>
38
+ ```
24
39
 
25
40
  ## Playground
26
41
 
@@ -51,14 +66,23 @@ ReadMore.init('.excerpt', {
51
66
  });
52
67
  ```
53
68
 
54
- `ReadMore.init()` also accepts a `NodeList` or an array of elements:
69
+ `ReadMore.init()` also accepts an element, or any iterable or array-like of elements (`NodeList`, `HTMLCollection`,
70
+ array, `Set`…). A third argument restricts a selector to a container:
55
71
 
56
72
  ```ts
57
73
  ReadMore.init(document.querySelectorAll<HTMLElement>('.excerpt'), {
58
74
  lines: 3
59
75
  });
76
+
77
+ // only the excerpts inside #news
78
+ ReadMore.init('.excerpt', {
79
+ lines: 3
80
+ }, document.querySelector('#news')!);
60
81
  ```
61
82
 
83
+ Calling `ReadMore.init()` again is safe: elements that are already initialized keep their instance (and its options) and
84
+ are returned as is, so you can re-run it after injecting new content.
85
+
62
86
  Or instantiate against a single element:
63
87
 
64
88
  ```ts
@@ -70,19 +94,71 @@ if (el) new ReadMore(el, {
70
94
  });
71
95
  ```
72
96
 
97
+ Each toggle also dispatches a bubbling `readmore:toggle` event on the element, handy for event delegation:
98
+
99
+ ```ts
100
+ document.addEventListener('readmore:toggle', (event) => {
101
+ const { expanded, instance } = event.detail;
102
+ });
103
+ ```
104
+
105
+ Pass `true` or `false` to `toggle()` to force a state, e.g. for an "Expand all" button. Instances already in that
106
+ state are left untouched and fire neither `onToggle` nor the event:
107
+
108
+ ```ts
109
+ const instances = ReadMore.init('.excerpt');
110
+
111
+ expandAllButton.addEventListener('click', () => instances.forEach((instance) => instance.toggle(true)));
112
+ ```
113
+
114
+ The clamp is re-measured when the element resizes and once web fonts finish loading. Content changes that keep the
115
+ element's size are not detected: call `refresh()` so the button is mounted or removed as needed:
116
+
117
+ ```ts
118
+ instance.el.textContent = newText;
119
+ instance.refresh();
120
+ ```
121
+
122
+ ### With a framework (React, Vue, Svelte…)
123
+
124
+ The instance holds a button outside the element and is watched by a `ResizeObserver`, so call `destroy()` when the
125
+ component unmounts. By default the button is inserted as a sibling of the element, which a framework may remove or
126
+ leave behind when it re-renders. The recommended fix is `buttonContainer`: pass an element the framework owns (a ref)
127
+ and the button is appended there instead. For example with React:
128
+
129
+ ```tsx
130
+ useEffect(() => {
131
+ const instance = new ReadMore(textRef.current!, {
132
+ buttonContainer: buttonRef.current!
133
+ });
134
+
135
+ return () => instance.destroy();
136
+ }, []);
137
+
138
+ return (
139
+ <>
140
+ <p ref={textRef}>{children}</p>
141
+ <div ref={buttonRef} />
142
+ </>
143
+ );
144
+ ```
145
+
73
146
  ## Options
74
147
 
75
- | Option | Type | Default | Description |
76
- |-----------------|-------------------------------|------------------|---------------------------------------------------------------------|
77
- | `lines` | `number` | `3` | Number of lines to clamp to (min `1`). Ignored if `height` is set. |
78
- | `height` | `number` | — | Max height in pixels (min `1`). Takes precedence over `lines`. |
79
- | `moreText` | `string` | `'Read more'` | Label of the button when the text is collapsed. |
80
- | `lessText` | `string` | `'Read less'` | Label of the button when the text is expanded. |
81
- | `buttonClass` | `string` | `'readmore-btn'` | Class applied to the toggle button. |
82
- | `expandedClass` | `string` | `'is-expanded'` | Class applied to the target element while expanded. |
83
- | `openingClass` | `string` | `'is-opening'` | Transient class applied while expanding, removed after transition. |
84
- | `closingClass` | `string` | `'is-closing'` | Transient class applied while collapsing, removed after transition. |
85
- | `onToggle` | `(expanded: boolean) => void` | — | Callback fired after each toggle with the current `expanded` state. |
148
+ | Option | Type | Default | Description |
149
+ |-------------------|---------------------------------------------------|------------------|-------------------------------------------------------------------------------------------------------|
150
+ | `lines` | `number` | `3` | Number of lines to clamp to (min `1`). Ignored if `height` is set. |
151
+ | `height` | `number` | — | Max height in pixels (greater than `0`). Takes precedence over `lines`. |
152
+ | `moreText` | `string` | `'Read more'` | Label of the button when the text is collapsed. |
153
+ | `lessText` | `string` | `'Read less'` | Label of the button when the text is expanded. |
154
+ | `moreLabel` | `string` | — | `aria-label` of the button when collapsed. Should contain `moreText`. |
155
+ | `lessLabel` | `string` | — | `aria-label` of the button when expanded. Should contain `lessText`. |
156
+ | `buttonClass` | `string` | `'readmore-btn'` | Class applied to the toggle button. |
157
+ | `buttonContainer` | `HTMLElement` | — | Element the button is appended to, instead of being inserted after the text. |
158
+ | `expandedClass` | `string` | `'is-expanded'` | Class applied to the target element while expanded. |
159
+ | `openingClass` | `string` | `'is-opening'` | Transient class applied while expanding, removed after transition. |
160
+ | `closingClass` | `string` | `'is-closing'` | Transient class applied while collapsing, removed after transition. |
161
+ | `onToggle` | `(expanded: boolean, instance: ReadMore) => void` | — | Callback fired after each toggle, before the `readmore:toggle` event (still dispatched if it throws). |
86
162
 
87
163
  ## API
88
164
 
@@ -90,13 +166,17 @@ if (el) new ReadMore(el, {
90
166
  const instance = new ReadMore(element, options);
91
167
 
92
168
  instance.el; // the target element
93
- instance.options; // resolved options
169
+ instance.options; // resolved options (read-only)
94
170
  instance.toggle(); // expand / collapse programmatically
171
+ instance.toggle(true); // force expand (false: force collapse); no-op if already in that state
95
172
  instance.expanded; // boolean getter
96
- instance.destroy(); // remove button, classes, listeners and generated id
173
+ instance.refresh(); // re-check the overflow after a content change
174
+ instance.destroy(); // remove button, classes, listeners and generated id; toggle() is a no-op afterwards
175
+
176
+ ReadMore.init(target, options, root); // target: selector | element | iterable of elements — returns ReadMore[], reusing existing instances
177
+ ReadMore.getInstance(element); // returns the ReadMore bound to an element, or undefined
97
178
 
98
- ReadMore.init(target, options); // target: selector | NodeList | HTMLElement[] — returns ReadMore[]
99
- ReadMore.getInstance(element); // returns the ReadMore bound to an element, or undefined
179
+ // event: 'readmore:toggle' (bubbles) — event.detail: { expanded, instance }
100
180
  ```
101
181
 
102
182
  ### Errors
@@ -107,29 +187,47 @@ The constructor throws instead of failing silently:
107
187
  |--------------|-------------------------------------------------------------------------------|
108
188
  | `TypeError` | `element` is not an `HTMLElement`. |
109
189
  | `Error` | The element is already initialized — call `destroy()` before re-initializing. |
110
- | `RangeError` | `lines` is lower than `1`, or `height` is lower than or equal to `0`. |
190
+ | `RangeError` | `lines` is not an integer `>= 1`, or `height` is not a finite number `> 0`. |
191
+
192
+ A `buttonContainer` that is not an `HTMLElement` also throws a `TypeError`.
193
+
194
+ `lines` and `height` also accept numeric strings such as `'3'` (e.g. read from a `data-*` attribute); blank or
195
+ non-numeric strings throw a `RangeError`.
111
196
 
112
197
  ## Accessibility
113
198
 
114
199
  When the button is mounted, it gets `aria-expanded` (kept in sync on every toggle) and `aria-controls`
115
- pointing to the target element. If the element has no `id`, a unique one (`readmore-1`, `readmore-2`, …)
116
- is generated and removed again on `destroy()`; an existing `id` is always preserved.
200
+ pointing to the target element. If the element has no `id`, a unique one (`readmore-1`, `readmore-2`, …, skipping ids
201
+ already used in the page) is generated and removed again on `destroy()`; an existing `id` is always preserved.
202
+
203
+ When several buttons share the same visible text, give each one a distinct accessible name with `moreLabel` and
204
+ `lessLabel`. Keep the visible text inside the label so voice control users can still activate the button by its text:
205
+
206
+ ```ts
207
+ new ReadMore(el, {
208
+ moreLabel: 'Read more about Product A',
209
+ lessLabel: 'Read less about Product A'
210
+ });
211
+ ```
117
212
 
118
213
  ## How it works
119
214
 
120
215
  The plugin sets a CSS custom property on the target element and toggles `.readmore-clamp`. In **lines mode** (default),
121
216
  it sets `--readmore-lines` and the bundled stylesheet applies `-webkit-line-clamp`, producing an ellipsis at the end of
122
217
  the last visible line. In **height mode** (`height` option), it sets `--readmore-height` and applies `max-height` via
123
- the `.readmore-clamp--height` modifier class instead. The toggle button is mounted right after the element only when the
124
- content actually overflows; a `ResizeObserver` watches the element and unmounts the button again if the text ends up
125
- fitting (or mounts it later if it starts overflowing).
218
+ the `.readmore-clamp--height` modifier class instead. The toggle button is mounted (right after the element, or inside
219
+ `buttonContainer` when set) only when the content actually overflows; a single `ResizeObserver`, shared by every instance, watches the elements and unmounts the
220
+ button again if the text ends up fitting (or mounts it later if it starts overflowing). All resized elements are
221
+ measured before any button is added or removed, to avoid a layout per element.
126
222
 
127
223
  ### Transient classes for animations
128
224
 
129
225
  In addition to `expandedClass` (which stays applied for the entire expanded state), the plugin applies short-lived
130
226
  `openingClass` and `closingClass` markers on the target element at the moment of expand and collapse respectively. Each
131
- is added when the toggle starts and removed automatically once the CSS transition on the element ends — or on the next
132
- tick if no transition is declared. Use them as hooks to drive enter/leave animations:
227
+ is added when the toggle starts and removed automatically once the longest CSS transition declared on the element
228
+ (duration + delay) has elapsed — or on the next tick if no transition is declared. The overflow check that mounts or
229
+ unmounts the button waits for that moment too, so the button stays in place during a collapse animation. Use them as
230
+ hooks to drive enter/leave animations:
133
231
 
134
232
  ```css
135
233
  .is-opening {
@@ -140,3 +238,35 @@ tick if no transition is declared. Use them as hooks to drive enter/leave animat
140
238
  /* collapse animation */
141
239
  }
142
240
  ```
241
+
242
+ ### Reduced motion
243
+
244
+ If you animate the expansion, turn the transition off for users who ask for less motion:
245
+
246
+ ```css
247
+ @media (prefers-reduced-motion: reduce) {
248
+ .excerpt {
249
+ transition: none;
250
+ }
251
+ }
252
+ ```
253
+
254
+ ### Fade in height mode
255
+
256
+ Add the `readmore-fade` class to the element to fade out the bottom of the clamped text in height mode (lines mode
257
+ already ends with an ellipsis). The fade is removed while expanded; its size defaults to `2em` and can be changed with
258
+ the `--readmore-fade-size` custom property:
259
+
260
+ ```html
261
+ <p class="excerpt readmore-fade" style="--readmore-fade-size: 3em">…</p>
262
+ ```
263
+
264
+ ## Limitations
265
+
266
+ - While collapsed, `.readmore-clamp` sets `display: -webkit-box` (lines mode) or `display: block` (height mode), which
267
+ overrides the element's own `display`. To clamp a flex or grid container, wrap its content in an inner element and
268
+ initialize the plugin on that element instead.
269
+ - In lines mode, how `-webkit-line-clamp` handles several block children (e.g. multiple `<p>`) varies between browsers.
270
+ For rich content, prefer height mode.
271
+ - Lines mode cannot be animated: neither `display` nor `line-clamp` can transition. For an animated expand/collapse, use
272
+ height mode with a `max-height` transition.
package/dist/readmore.css CHANGED
@@ -1 +1 @@
1
- .readmore-clamp{-webkit-line-clamp:var(--readmore-lines,3);line-clamp:var(--readmore-lines,3);-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.readmore-clamp--height{max-height:var(--readmore-height);-webkit-line-clamp:none;line-clamp:none;display:block}
1
+ .readmore-clamp{-webkit-line-clamp:var(--readmore-lines,3);line-clamp:var(--readmore-lines,3);-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.readmore-clamp--height{max-height:var(--readmore-height);-webkit-line-clamp:none;line-clamp:none;display:block}.readmore-fade.readmore-clamp--height{-webkit-mask-image:linear-gradient(to bottom, #000 calc(100% - var(--readmore-fade-size,2em)), transparent);mask-image:linear-gradient(to bottom, #000 calc(100% - var(--readmore-fade-size,2em)), transparent)}
@@ -6,14 +6,14 @@ export interface ReadMoreOptions {
6
6
  * Number of lines to clamp to. Ignored when {@link ReadMoreOptions.height} is set.
7
7
  *
8
8
  * @defaultValue 3
9
- * @throws RangeError - if lower than 1.
9
+ * @throws RangeError - if not an integer greater than or equal to 1.
10
10
  */
11
11
  lines?: number;
12
12
  /**
13
13
  * Max height in pixels. Takes precedence over {@link ReadMoreOptions.lines}
14
14
  * and clamps with `max-height` instead of `-webkit-line-clamp`.
15
15
  *
16
- * @throws RangeError - if lower than or equal to 0.
16
+ * @throws RangeError - if not a finite number greater than 0.
17
17
  */
18
18
  height?: number;
19
19
  /**
@@ -28,12 +28,31 @@ export interface ReadMoreOptions {
28
28
  * @defaultValue 'Read less'
29
29
  */
30
30
  lessText?: string;
31
+ /**
32
+ * Accessible name (`aria-label`) of the toggle button while the text is
33
+ * collapsed, e.g. `'Read more about Product A'` when several buttons share
34
+ * the same visible text. It should contain {@link ReadMoreOptions.moreText}.
35
+ */
36
+ moreLabel?: string;
37
+ /**
38
+ * Accessible name (`aria-label`) of the toggle button while the text is
39
+ * expanded. It should contain {@link ReadMoreOptions.lessText}.
40
+ */
41
+ lessLabel?: string;
31
42
  /**
32
43
  * Class applied to the toggle button.
33
44
  *
34
45
  * @defaultValue 'readmore-btn'
35
46
  */
36
47
  buttonClass?: string;
48
+ /**
49
+ * Element the toggle button is appended to, instead of being inserted right
50
+ * after the clamped element. Use an element owned by your framework (a ref)
51
+ * so reconciliation cannot drop the button.
52
+ *
53
+ * @throws TypeError - if not an `HTMLElement`.
54
+ */
55
+ buttonContainer?: HTMLElement;
37
56
  /**
38
57
  * Class applied to the target element for the whole expanded state.
39
58
  *
@@ -41,34 +60,51 @@ export interface ReadMoreOptions {
41
60
  */
42
61
  expandedClass?: string;
43
62
  /**
44
- * Transient class applied when expanding, removed once the CSS transition
45
- * on the element ends — or on the next tick if no transition is declared.
63
+ * Transient class applied when expanding, removed once the longest CSS
64
+ * transition on the element (duration + delay) has elapsed — or on the next
65
+ * tick if no transition is declared.
46
66
  *
47
67
  * @defaultValue 'is-opening'
48
68
  */
49
69
  openingClass?: string;
50
70
  /**
51
- * Transient class applied when collapsing, removed once the CSS transition
52
- * on the element ends — or on the next tick if no transition is declared.
71
+ * Transient class applied when collapsing, removed once the longest CSS
72
+ * transition on the element (duration + delay) has elapsed — or on the next
73
+ * tick if no transition is declared.
53
74
  *
54
75
  * @defaultValue 'is-closing'
55
76
  */
56
77
  closingClass?: string;
57
78
  /**
58
- * Called after each toggle with the resulting state.
79
+ * Called after each toggle with the resulting state, right before the
80
+ * `readmore:toggle` event is dispatched.
59
81
  *
60
82
  * @param expanded - `true` once expanded, `false` once collapsed.
83
+ * @param instance - The instance that was toggled.
61
84
  */
62
- onToggle?: (expanded: boolean) => void;
85
+ onToggle?: (expanded: boolean, instance: ReadMore) => void;
63
86
  }
87
+ type OptionalKeys = 'height' | 'buttonContainer' | 'moreLabel' | 'lessLabel' | 'onToggle';
64
88
  /**
65
89
  * {@link ReadMoreOptions} merged with the defaults, as exposed on
66
90
  * {@link ReadMore.options}.
67
91
  */
68
- export type ResolvedOptions = Required<Omit<ReadMoreOptions, 'height' | 'onToggle'>> & {
69
- height?: number;
70
- onToggle?: (expanded: boolean) => void;
71
- };
92
+ export type ResolvedOptions = Required<Omit<ReadMoreOptions, OptionalKeys>> & Pick<ReadMoreOptions, OptionalKeys>;
93
+ /** `detail` of the `readmore:toggle` event. */
94
+ export interface ReadMoreToggleDetail {
95
+ /** `true` once expanded, `false` once collapsed. */
96
+ expanded: boolean;
97
+ /** The instance that was toggled. */
98
+ instance: ReadMore;
99
+ }
100
+ declare global {
101
+ interface HTMLElementEventMap {
102
+ 'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;
103
+ }
104
+ interface DocumentEventMap {
105
+ 'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;
106
+ }
107
+ }
72
108
  /**
73
109
  * Clamps an element to a number of lines (or a fixed height) and mounts a
74
110
  * `Read more` / `Read less` button right after it, but only while the content
@@ -87,33 +123,51 @@ export declare class ReadMore {
87
123
  /** The clamped element. */
88
124
  readonly el: HTMLElement;
89
125
  /** The options this instance was built with, merged with the defaults. */
90
- readonly options: ResolvedOptions;
126
+ readonly options: Readonly<ResolvedOptions>;
91
127
  /**
92
128
  * @param element - The element to clamp.
93
129
  * @param options - See {@link ReadMoreOptions}.
94
130
  * @throws TypeError - if `element` is not an `HTMLElement`.
95
131
  * @throws Error - if `element` is already initialized; call
96
132
  * {@link ReadMore.destroy} first.
97
- * @throws RangeError - if `lines` is lower than 1, or `height` lower than
98
- * or equal to 0.
133
+ * @throws RangeError - if `lines` is not an integer greater than or equal
134
+ * to 1, or `height` is not a finite number greater than 0.
135
+ * @throws TypeError - if `buttonContainer` is set but is not an `HTMLElement`.
99
136
  */
100
137
  constructor(element: HTMLElement, options?: ReadMoreOptions);
101
138
  /**
102
- * Instantiates {@link ReadMore} on every matching element.
139
+ * Instantiates {@link ReadMore} on every matching element. Elements that are
140
+ * already initialized are left untouched and their existing instance is
141
+ * returned, so it is safe to call again after new content is added.
103
142
  *
104
- * @param target - A CSS selector, a `NodeList` or an array of elements.
105
- * @param options - See {@link ReadMoreOptions}.
143
+ * @param target - A CSS selector, an element, or any iterable or array-like
144
+ * of elements (`NodeList`, `HTMLCollection`, array, `Set`…).
145
+ * @param options - See {@link ReadMoreOptions}. Not applied to elements
146
+ * that are already initialized.
147
+ * @param root - Where to look for `target` when it is a selector.
106
148
  * @returns One instance per element, in document order.
107
149
  */
108
- static init(target: string | NodeListOf<HTMLElement> | HTMLElement[], options?: ReadMoreOptions): ReadMore[];
150
+ static init(target: string | HTMLElement | Iterable<HTMLElement> | ArrayLike<HTMLElement>, options?: ReadMoreOptions, root?: ParentNode): ReadMore[];
109
151
  /** Whether the text is currently expanded. */
110
152
  get expanded(): boolean;
111
153
  /**
112
154
  * Expands or collapses the text, updating the button label, the
113
155
  * `aria-expanded` attribute and the state classes, then calls
114
- * {@link ReadMoreOptions.onToggle}.
156
+ * {@link ReadMoreOptions.onToggle} and dispatches a bubbling
157
+ * `readmore:toggle` event on the element. Does nothing once destroyed.
158
+ *
159
+ * @param force - `true` to expand, `false` to collapse; the state is
160
+ * inverted when omitted. Does nothing if the text is already in the
161
+ * requested state.
162
+ */
163
+ toggle(force?: boolean): void;
164
+ /**
165
+ * Checks again whether the content overflows, mounting or unmounting the
166
+ * button. Resizes of the element are already tracked; call it after
167
+ * changing the content in a way that keeps the element's size. Does nothing
168
+ * while expanded, during a toggle transition, or once destroyed.
115
169
  */
116
- toggle(): void;
170
+ refresh(): void;
117
171
  /**
118
172
  * @param element - An element possibly initialized by this plugin.
119
173
  * @returns The instance bound to it, or `undefined`.
@@ -122,7 +176,8 @@ export declare class ReadMore {
122
176
  /**
123
177
  * Restores the element: removes the button, the classes, the CSS custom
124
178
  * properties, the listeners and the generated `id`. The element can then be
125
- * initialized again.
179
+ * initialized again. Calling it again does nothing.
126
180
  */
127
181
  destroy(): void;
128
182
  }
183
+ export {};
@@ -0,0 +1,2 @@
1
+ (function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var t=`readmore-clamp`,n=`readmore-clamp--height`,r=/* @__PURE__ */ new WeakMap,i=0,a={lines:3,moreText:`Read more`,lessText:`Read less`,buttonClass:`readmore-btn`,expandedClass:`is-expanded`,openingClass:`is-opening`,closingClass:`is-closing`};function o(e){return e.split(`,`).map(e=>parseFloat(e)*1e3||0)}e.ReadMore=class e{static#e;static#t=null;static#n=null;el;options;#r=null;#i=!1;#a=!1;#o=null;#s=()=>this.toggle();get#c(){return r.get(this.el)!==this}constructor(e,t={}){if(!(e instanceof HTMLElement))throw TypeError(`ReadMore: an HTMLElement is required.`);if(r.has(e))throw Error(`ReadMore: element is already initialized. Call destroy() first.`);this.el=e,this.options={...a,...Object.fromEntries(Object.entries(t).filter(e=>e[1]!==void 0))};for(let e of[`height`,`lines`]){let t=this.options[e];typeof t==`string`&&t.trim()!==``&&(this.options[e]=Number(t))}let{height:n,lines:i,buttonContainer:o}=this.options;if(o!=null&&!(o instanceof HTMLElement))throw TypeError(`ReadMore: buttonContainer must be an HTMLElement.`);if(n!=null&&!(Number.isFinite(n)&&n>0))throw RangeError(`ReadMore: height must be a finite number greater than 0.`);if(!(Number.isInteger(i)&&i>=1))throw RangeError(`ReadMore: lines must be an integer of at least 1.`);r.set(e,this),this.#d()}static init(t,n,i=document){let a=typeof t==`string`?i.querySelectorAll(t):t instanceof HTMLElement?[t]:t,o=[];e.#t=o;try{return Array.from(a).map(t=>r.get(t)??new e(t,n))}finally{e.#t=null,e.#u(o)}}get expanded(){return this.#i}toggle(e){let r=typeof e==`boolean`?e:!this.#i;if(this.#c||r===this.#i)return;this.#i=r,this.el.classList.toggle(t,!this.#i),this.options.height!=null&&this.el.classList.toggle(n,!this.#i),this.el.classList.toggle(this.options.expandedClass,this.#i),this.#v();let i=this.#i?this.options.openingClass:this.options.closingClass,a=this.#i?this.options.closingClass:this.options.openingClass;this.el.classList.remove(a),this.#h(i);try{this.options.onToggle?.(this.#i,this)}finally{this.el.dispatchEvent(new CustomEvent(`readmore:toggle`,{bubbles:!0,detail:{expanded:this.#i,instance:this}}))}}refresh(){this.#m(this.#p())}static getInstance(e){return r.get(e)}destroy(){this.#c||(r.delete(this.el),e.#e?.unobserve(this.el),this.#o!==null&&(clearTimeout(this.#o),this.#o=null),this.#y(),this.el.classList.remove(t,n,this.options.expandedClass,this.options.openingClass,this.options.closingClass),this.el.style.removeProperty(`--readmore-lines`),this.el.style.removeProperty(`--readmore-height`),this.#a&&=(this.el.removeAttribute(`id`),!1))}static#l(t){e.#u(t.map(e=>r.get(e.target)))}static#u(e){let t=/* @__PURE__ */ new Map;for(let n of e)n&&t.set(n,n.#p());t.forEach((e,t)=>t.#m(e))}#d(){this.options.height==null?(this.el.style.setProperty(`--readmore-lines`,String(this.options.lines)),this.el.classList.add(t)):(this.el.style.setProperty(`--readmore-height`,`${this.options.height}px`),this.el.classList.add(t,n)),e.#e??=new ResizeObserver(e.#l),e.#e.observe(this.el),e.#t?e.#t.push(this):this.refresh(),this.#f()}#f(){if(typeof document>`u`||!document.fonts)return;if(e.#n){e.#n.push(this);return}let t=e.#n=[this];document.fonts.ready.then(()=>{e.#n=null,e.#u(t.filter(e=>!e.#c))})}#p(){return this.el.scrollHeight-this.el.clientHeight>1}#m(e){this.#i||this.#o!==null||this.#c||(e&&!this.#r?this.#_():!e&&this.#r&&this.#y())}#h(e){this.#o!==null&&clearTimeout(this.#o),this.el.classList.add(e);let t=setTimeout(()=>{let n=this.el.getAnimations?.().filter(e=>e instanceof CSSTransition)??[],r=()=>{this.#o===t&&(this.#o=null,this.el.classList.remove(e),this.refresh())};n.length?Promise.allSettled(n.map(e=>e.finished)).then(r):r()},this.#g());this.#o=t}#g(){let e=getComputedStyle(this.el),t=o(e.transitionDuration),n=o(e.transitionDelay),r=0;for(let e=0;e<Math.max(t.length,n.length);e++)r=Math.max(r,t[e%t.length]+n[e%n.length]);return r}#_(){if(!this.el.id){let e;do e=`readmore-${++i}`;while(document.getElementById(e));this.el.id=e,this.#a=!0}let e=document.createElement(`button`);e.type=`button`,e.className=this.options.buttonClass,e.setAttribute(`aria-controls`,this.el.id),e.addEventListener(`click`,this.#s),this.#r=e,this.#v(),this.options.buttonContainer?this.options.buttonContainer.appendChild(e):this.el.insertAdjacentElement(`afterend`,e)}#v(){if(!this.#r)return;let{moreText:e,lessText:t,moreLabel:n,lessLabel:r}=this.options,i=this.#i?r:n;this.#r.textContent=this.#i?t:e,this.#r.setAttribute(`aria-expanded`,String(this.#i)),i?this.#r.setAttribute(`aria-label`,i):this.#r.removeAttribute(`aria-label`)}#y(){if(this.#r){if(this.#r.removeEventListener(`click`,this.#s),document.activeElement===this.#r){let{el:e}=this;e.hasAttribute(`tabindex`)||(e.tabIndex=-1,e.addEventListener(`blur`,()=>e.removeAttribute(`tabindex`),{once:!0})),e.focus()}this.#r.remove(),this.#r=null}}}})(this.FrontFactory=this.FrontFactory||{});
2
+ //# sourceMappingURL=readmore.iife.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readmore.iife.js","names":["exports","CLAMP_CLASS","CLAMP_HEIGHT_CLASS","instances","idCounter","DEFAULTS","toMs","list","v","ReadMore","#resizeObserver","#batch","#fontQueue","#destroyed","element","options","entry","key","value","buttonContainer","height","lines","#init","nodes","target","root","batch","n","#syncAll","#expanded","expanded","force","#updateButton","stateClass","otherClass","#applyTransientClass","#sync","#isOverflowing","#transientTimer","#unmountButton","#generatedId","#onResize","entries","measured","instance","overflowing","#refreshOnFonts","queue","#button","#mountButton","timer","running","a","done","#transitionTime","style","durations","delays","max","i","id","btn","#onClick","label","lessLabel","moreLabel","lessText","moreText","el"],"sources":["../src/readmore.ts"],"sourcesContent":["/**\n * Options accepted by {@link ReadMore}. Every field is optional.\n */\nexport interface ReadMoreOptions {\n /**\n * Number of lines to clamp to. Ignored when {@link ReadMoreOptions.height} is set.\n *\n * @defaultValue 3\n * @throws RangeError - if not an integer greater than or equal to 1.\n */\n lines?: number;\n\n /**\n * Max height in pixels. Takes precedence over {@link ReadMoreOptions.lines}\n * and clamps with `max-height` instead of `-webkit-line-clamp`.\n *\n * @throws RangeError - if not a finite number greater than 0.\n */\n height?: number;\n\n /**\n * Label of the toggle button while the text is collapsed.\n *\n * @defaultValue 'Read more'\n */\n moreText?: string;\n\n /**\n * Label of the toggle button while the text is expanded.\n *\n * @defaultValue 'Read less'\n */\n lessText?: string;\n\n /**\n * Accessible name (`aria-label`) of the toggle button while the text is\n * collapsed, e.g. `'Read more about Product A'` when several buttons share\n * the same visible text. It should contain {@link ReadMoreOptions.moreText}.\n */\n moreLabel?: string;\n\n /**\n * Accessible name (`aria-label`) of the toggle button while the text is\n * expanded. It should contain {@link ReadMoreOptions.lessText}.\n */\n lessLabel?: string;\n\n /**\n * Class applied to the toggle button.\n *\n * @defaultValue 'readmore-btn'\n */\n buttonClass?: string;\n\n /**\n * Element the toggle button is appended to, instead of being inserted right\n * after the clamped element. Use an element owned by your framework (a ref)\n * so reconciliation cannot drop the button.\n *\n * @throws TypeError - if not an `HTMLElement`.\n */\n buttonContainer?: HTMLElement;\n\n /**\n * Class applied to the target element for the whole expanded state.\n *\n * @defaultValue 'is-expanded'\n */\n expandedClass?: string;\n\n /**\n * Transient class applied when expanding, removed once the longest CSS\n * transition on the element (duration + delay) has elapsed — or on the next\n * tick if no transition is declared.\n *\n * @defaultValue 'is-opening'\n */\n openingClass?: string;\n\n /**\n * Transient class applied when collapsing, removed once the longest CSS\n * transition on the element (duration + delay) has elapsed — or on the next\n * tick if no transition is declared.\n *\n * @defaultValue 'is-closing'\n */\n closingClass?: string;\n\n /**\n * Called after each toggle with the resulting state, right before the\n * `readmore:toggle` event is dispatched.\n *\n * @param expanded - `true` once expanded, `false` once collapsed.\n * @param instance - The instance that was toggled.\n */\n onToggle?: (expanded: boolean, instance: ReadMore) => void;\n}\n\ntype OptionalKeys = 'height' | 'buttonContainer' | 'moreLabel' | 'lessLabel' | 'onToggle';\n\n/**\n * {@link ReadMoreOptions} merged with the defaults, as exposed on\n * {@link ReadMore.options}.\n */\nexport type ResolvedOptions = Required<Omit<ReadMoreOptions, OptionalKeys>> & Pick<ReadMoreOptions, OptionalKeys>;\n\n/** `detail` of the `readmore:toggle` event. */\nexport interface ReadMoreToggleDetail {\n /** `true` once expanded, `false` once collapsed. */\n expanded: boolean;\n\n /** The instance that was toggled. */\n instance: ReadMore;\n}\n\n// Types `event.detail` in `addEventListener('readmore:toggle', …)`, on elements\n// and on `document` (the event bubbles).\ndeclare global {\n interface HTMLElementEventMap {\n 'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;\n }\n\n interface DocumentEventMap {\n 'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;\n }\n}\n\nconst CLAMP_CLASS = 'readmore-clamp';\nconst CLAMP_HEIGHT_CLASS = 'readmore-clamp--height';\n\nconst instances = new WeakMap<HTMLElement, ReadMore>();\nlet idCounter = 0;\n\nconst DEFAULTS: ResolvedOptions = {\n lines: 3,\n moreText: 'Read more',\n lessText: 'Read less',\n buttonClass: 'readmore-btn',\n expandedClass: 'is-expanded',\n openingClass: 'is-opening',\n closingClass: 'is-closing'\n};\n\n/**\n * Parses a computed `transition-duration` / `transition-delay` list into\n * milliseconds. Computed times are always serialized in seconds.\n */\nfunction toMs(list: string): number[] {\n return list.split(',').map((v) => parseFloat(v) * 1000 || 0);\n}\n\n/**\n * Clamps an element to a number of lines (or a fixed height) and mounts a\n * `Read more` / `Read less` button right after it, but only while the content\n * actually overflows.\n *\n * @example\n * ```ts\n * import { ReadMore } from '@frontfactory/readmore';\n * import '@frontfactory/readmore/style.css';\n *\n * ReadMore.init('.excerpt', { lines: 3 });\n * ```\n */\nexport class ReadMore {\n /** Shared by every instance, created with the first one. */\n static #resizeObserver: ResizeObserver | undefined;\n\n /** Instances created by the running {@link ReadMore.init}, synced together once all exist. */\n static #batch: ReadMore[] | null = null;\n\n /** Instances waiting for `document.fonts.ready`, measured together. */\n static #fontQueue: ReadMore[] | null = null;\n\n /** The clamped element. */\n readonly el: HTMLElement;\n\n /** The options this instance was built with, merged with the defaults. */\n readonly options: Readonly<ResolvedOptions>;\n\n #button: HTMLButtonElement | null = null;\n #expanded = false;\n #generatedId = false;\n #transientTimer: ReturnType<typeof setTimeout> | null = null;\n #onClick = (): void => this.toggle();\n\n // Destroyed once the element is no longer bound to this instance: after\n // destroy(), or if the element was re-initialized by another instance.\n get #destroyed(): boolean {\n return instances.get(this.el) !== this;\n }\n\n /**\n * @param element - The element to clamp.\n * @param options - See {@link ReadMoreOptions}.\n * @throws TypeError - if `element` is not an `HTMLElement`.\n * @throws Error - if `element` is already initialized; call\n * {@link ReadMore.destroy} first.\n * @throws RangeError - if `lines` is not an integer greater than or equal\n * to 1, or `height` is not a finite number greater than 0.\n * @throws TypeError - if `buttonContainer` is set but is not an `HTMLElement`.\n */\n constructor(element: HTMLElement, options: ReadMoreOptions = {}) {\n if (!(element instanceof HTMLElement)) {\n throw new TypeError('ReadMore: an HTMLElement is required.');\n }\n\n if (instances.has(element)) {\n throw new Error('ReadMore: element is already initialized. Call destroy() first.');\n }\n\n this.el = element;\n // Options explicitly set to `undefined` must not override the defaults.\n this.options = {\n ...DEFAULTS,\n ...Object.fromEntries(Object.entries(options).filter((entry) => entry[1] !== undefined))\n };\n\n // Numeric strings (data-* attributes, framework props) are accepted; blank or non-numeric ones still fail below.\n for (const key of [\n 'height',\n 'lines'\n ] as const) {\n const value: unknown = this.options[key];\n\n if (typeof value === 'string' && value.trim() !== '') {\n (this.options as Record<string, unknown>)[key] = Number(value);\n }\n }\n\n const { height, lines, buttonContainer } = this.options;\n\n if (buttonContainer != null && !(buttonContainer instanceof HTMLElement)) {\n throw new TypeError('ReadMore: buttonContainer must be an HTMLElement.');\n }\n\n if (height != null && !(Number.isFinite(height) && height > 0)) {\n throw new RangeError('ReadMore: height must be a finite number greater than 0.');\n }\n\n if (!(Number.isInteger(lines) && lines >= 1)) {\n throw new RangeError('ReadMore: lines must be an integer of at least 1.');\n }\n\n instances.set(element, this);\n this.#init();\n }\n\n /**\n * Instantiates {@link ReadMore} on every matching element. Elements that are\n * already initialized are left untouched and their existing instance is\n * returned, so it is safe to call again after new content is added.\n *\n * @param target - A CSS selector, an element, or any iterable or array-like\n * of elements (`NodeList`, `HTMLCollection`, array, `Set`…).\n * @param options - See {@link ReadMoreOptions}. Not applied to elements\n * that are already initialized.\n * @param root - Where to look for `target` when it is a selector.\n * @returns One instance per element, in document order.\n */\n static init(\n target: string | HTMLElement | Iterable<HTMLElement> | ArrayLike<HTMLElement>,\n options?: ReadMoreOptions,\n root: ParentNode = document\n ): ReadMore[] {\n const nodes =\n typeof target === 'string'\n ? root.querySelectorAll<HTMLElement>(target)\n : target instanceof HTMLElement ? [\n target\n ] : target;\n\n const batch: ReadMore[] = [];\n\n ReadMore.#batch = batch;\n\n try {\n return Array.from(nodes).map((n) => instances.get(n) ?? new ReadMore(n, options));\n } finally {\n ReadMore.#batch = null;\n ReadMore.#syncAll(batch);\n }\n }\n\n /** Whether the text is currently expanded. */\n get expanded(): boolean {\n return this.#expanded;\n }\n\n /**\n * Expands or collapses the text, updating the button label, the\n * `aria-expanded` attribute and the state classes, then calls\n * {@link ReadMoreOptions.onToggle} and dispatches a bubbling\n * `readmore:toggle` event on the element. Does nothing once destroyed.\n *\n * @param force - `true` to expand, `false` to collapse; the state is\n * inverted when omitted. Does nothing if the text is already in the\n * requested state.\n */\n toggle(force?: boolean): void {\n // Only a real boolean forces the state, so `toggle` still works as an\n // event listener (`addEventListener('click', rm.toggle.bind(rm))`).\n const expanded = typeof force === 'boolean' ? force : !this.#expanded;\n\n if (this.#destroyed || expanded === this.#expanded) {\n return;\n }\n\n this.#expanded = expanded;\n this.el.classList.toggle(CLAMP_CLASS, !this.#expanded);\n\n if (this.options.height != null) {\n this.el.classList.toggle(CLAMP_HEIGHT_CLASS, !this.#expanded);\n }\n\n this.el.classList.toggle(this.options.expandedClass, this.#expanded);\n this.#updateButton();\n\n const stateClass = this.#expanded ? this.options.openingClass : this.options.closingClass;\n const otherClass = this.#expanded ? this.options.closingClass : this.options.openingClass;\n\n this.el.classList.remove(otherClass);\n this.#applyTransientClass(stateClass);\n\n try {\n this.options.onToggle?.(this.#expanded, this);\n } finally {\n // The event is dispatched even if `onToggle` throws (the error still propagates).\n this.el.dispatchEvent(new CustomEvent<ReadMoreToggleDetail>('readmore:toggle', {\n bubbles: true,\n detail: {\n expanded: this.#expanded,\n instance: this\n }\n }));\n }\n }\n\n /**\n * Checks again whether the content overflows, mounting or unmounting the\n * button. Resizes of the element are already tracked; call it after\n * changing the content in a way that keeps the element's size. Does nothing\n * while expanded, during a toggle transition, or once destroyed.\n */\n refresh(): void {\n this.#sync(this.#isOverflowing());\n }\n\n /**\n * @param element - An element possibly initialized by this plugin.\n * @returns The instance bound to it, or `undefined`.\n */\n static getInstance(element: HTMLElement): ReadMore | undefined {\n return instances.get(element);\n }\n\n /**\n * Restores the element: removes the button, the classes, the CSS custom\n * properties, the listeners and the generated `id`. The element can then be\n * initialized again. Calling it again does nothing.\n */\n destroy(): void {\n if (this.#destroyed) {\n return;\n }\n\n instances.delete(this.el);\n ReadMore.#resizeObserver?.unobserve(this.el);\n\n if (this.#transientTimer !== null) {\n clearTimeout(this.#transientTimer);\n this.#transientTimer = null;\n }\n\n this.#unmountButton();\n this.el.classList.remove(\n CLAMP_CLASS,\n CLAMP_HEIGHT_CLASS,\n this.options.expandedClass,\n this.options.openingClass,\n this.options.closingClass\n );\n\n this.el.style.removeProperty('--readmore-lines');\n this.el.style.removeProperty('--readmore-height');\n\n if (this.#generatedId) {\n this.el.removeAttribute('id');\n this.#generatedId = false;\n }\n }\n\n // Measures every resized element before touching the DOM, so mounting a\n // button does not force a new layout for each following element.\n static #onResize(entries: ResizeObserverEntry[]): void {\n ReadMore.#syncAll(entries.map((entry) => instances.get(entry.target as HTMLElement)));\n }\n\n static #syncAll(list: (ReadMore | undefined)[]): void {\n const measured = new Map<ReadMore, boolean>();\n\n for (const instance of list) {\n if (instance) {\n measured.set(instance, instance.#isOverflowing());\n }\n }\n\n measured.forEach((overflowing, instance) => instance.#sync(overflowing));\n }\n\n #init(): void {\n if (this.options.height != null) {\n this.el.style.setProperty('--readmore-height', `${ this.options.height }px`);\n this.el.classList.add(CLAMP_CLASS, CLAMP_HEIGHT_CLASS);\n } else {\n this.el.style.setProperty('--readmore-lines', String(this.options.lines));\n this.el.classList.add(CLAMP_CLASS);\n }\n\n ReadMore.#resizeObserver ??= new ResizeObserver(ReadMore.#onResize);\n ReadMore.#resizeObserver.observe(this.el);\n\n // Inside init(), the batch measures all elements at once afterwards.\n if (ReadMore.#batch) {\n ReadMore.#batch.push(this);\n } else {\n this.refresh();\n }\n\n this.#refreshOnFonts();\n }\n\n // A font swap can change the overflow without resizing the element, which the\n // ResizeObserver would miss. Instances created in the same tick share one\n // measurement; destroyed ones are skipped by #sync.\n #refreshOnFonts(): void {\n if (typeof document === 'undefined' || !document.fonts) {\n return;\n }\n\n if (ReadMore.#fontQueue) {\n ReadMore.#fontQueue.push(this);\n\n return;\n }\n\n const queue: ReadMore[] = ReadMore.#fontQueue = [\n this\n ];\n\n void document.fonts.ready.then(() => {\n ReadMore.#fontQueue = null;\n ReadMore.#syncAll(queue.filter((instance) => !instance.#destroyed));\n });\n }\n\n #isOverflowing(): boolean {\n return this.el.scrollHeight - this.el.clientHeight > 1;\n }\n\n #sync(overflowing: boolean): void {\n // While a toggle transition runs, the element's size is not final: measuring\n // it mid-collapse would remove the button. The timer refreshes once it ends.\n if (this.#expanded || this.#transientTimer !== null || this.#destroyed) {\n return;\n }\n\n if (overflowing && !this.#button) {\n this.#mountButton();\n } else if (!overflowing && this.#button) {\n this.#unmountButton();\n }\n }\n\n #applyTransientClass(stateClass: string): void {\n if (this.#transientTimer !== null) {\n clearTimeout(this.#transientTimer);\n }\n\n this.el.classList.add(stateClass);\n\n // A timer rather than `transitionend`, which never fires when no declared\n // property actually changes (e.g. `transition: all` in lines mode).\n const timer = setTimeout(() => {\n // Some engines (WebKit) start the transition later than the timer assumes:\n // wait for it to end, or the size is measured mid-way and the button remounted.\n const running = this.el.getAnimations?.().filter(a => a instanceof CSSTransition) ?? [];\n const done = () => {\n // Superseded by another toggle, or destroyed meanwhile.\n if (this.#transientTimer !== timer) {\n return;\n }\n\n this.#transientTimer = null;\n this.el.classList.remove(stateClass);\n this.refresh();\n };\n\n if (running.length) {\n Promise.allSettled(running.map(a => a.finished)).then(done);\n } else {\n done();\n }\n }, this.#transitionTime());\n\n this.#transientTimer = timer;\n }\n\n /** Longest `duration + delay` among the transitions declared on the element, in ms. */\n #transitionTime(): number {\n const style = getComputedStyle(this.el);\n const durations = toMs(style.transitionDuration);\n const delays = toMs(style.transitionDelay);\n let max = 0;\n\n // Shorter lists repeat to match the longer one, as CSS does.\n for (let i = 0; i < Math.max(durations.length, delays.length); i++) {\n max = Math.max(max, durations[i % durations.length] + delays[i % delays.length]);\n }\n\n return max;\n }\n\n #mountButton(): void {\n if (!this.el.id) {\n let id: string;\n\n // Skip ids already used in the page (server-rendered markup, another copy of the plugin…).\n do {\n id = `readmore-${++idCounter}`;\n } while (document.getElementById(id));\n\n this.el.id = id;\n this.#generatedId = true;\n }\n\n const btn = document.createElement('button');\n\n btn.type = 'button';\n btn.className = this.options.buttonClass;\n btn.setAttribute('aria-controls', this.el.id);\n btn.addEventListener('click', this.#onClick);\n this.#button = btn;\n this.#updateButton();\n\n if (this.options.buttonContainer) {\n this.options.buttonContainer.appendChild(btn);\n } else {\n this.el.insertAdjacentElement('afterend', btn);\n }\n }\n\n /** Syncs the button's text, `aria-expanded` and `aria-label` with the state. */\n #updateButton(): void {\n if (!this.#button) {\n return;\n }\n\n const { moreText, lessText, moreLabel, lessLabel } = this.options;\n const label = this.#expanded ? lessLabel : moreLabel;\n\n this.#button.textContent = this.#expanded ? lessText : moreText;\n this.#button.setAttribute('aria-expanded', String(this.#expanded));\n\n if (label) {\n this.#button.setAttribute('aria-label', label);\n } else {\n this.#button.removeAttribute('aria-label');\n }\n }\n\n #unmountButton(): void {\n if (!this.#button) {\n return;\n }\n\n this.#button.removeEventListener('click', this.#onClick);\n\n // Removing the focused button would drop focus to <body>: keep it on the element.\n if (document.activeElement === this.#button) {\n const { el } = this;\n\n if (!el.hasAttribute('tabindex')) {\n el.tabIndex = -1;\n el.addEventListener('blur', () => el.removeAttribute('tabindex'), {\n once: true\n });\n }\n\n el.focus();\n }\n\n this.#button.remove();\n this.#button = null;\n }\n}\n"],"mappings":"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"}
package/dist/readmore.js CHANGED
@@ -1,5 +1,9 @@
1
- //#region src/readmore.ts
2
- var e = "readmore-clamp", t = "readmore-clamp--height", n = /* @__PURE__ */ new WeakMap(), r = 0, i = {
1
+ // src/readmore.ts
2
+ var CLAMP_CLASS = "readmore-clamp";
3
+ var CLAMP_HEIGHT_CLASS = "readmore-clamp--height";
4
+ var instances = /* @__PURE__ */ new WeakMap();
5
+ var idCounter = 0;
6
+ var DEFAULTS = {
3
7
  lines: 3,
4
8
  moreText: "Read more",
5
9
  lessText: "Read less",
@@ -7,74 +11,285 @@ var e = "readmore-clamp", t = "readmore-clamp--height", n = /* @__PURE__ */ new
7
11
  expandedClass: "is-expanded",
8
12
  openingClass: "is-opening",
9
13
  closingClass: "is-closing"
10
- }, a = class a {
14
+ };
15
+ /**
16
+ * Parses a computed `transition-duration` / `transition-delay` list into
17
+ * milliseconds. Computed times are always serialized in seconds.
18
+ */
19
+ function toMs(list) {
20
+ return list.split(",").map((v) => parseFloat(v) * 1e3 || 0);
21
+ }
22
+ /**
23
+ * Clamps an element to a number of lines (or a fixed height) and mounts a
24
+ * `Read more` / `Read less` button right after it, but only while the content
25
+ * actually overflows.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * import { ReadMore } from '@frontfactory/readmore';
30
+ * import '@frontfactory/readmore/style.css';
31
+ *
32
+ * ReadMore.init('.excerpt', { lines: 3 });
33
+ * ```
34
+ */
35
+ var ReadMore = class ReadMore {
36
+ /** Shared by every instance, created with the first one. */
37
+ static #resizeObserver;
38
+ /** Instances created by the running {@link ReadMore.init}, synced together once all exist. */
39
+ static #batch = null;
40
+ /** Instances waiting for `document.fonts.ready`, measured together. */
41
+ static #fontQueue = null;
42
+ /** The clamped element. */
11
43
  el;
44
+ /** The options this instance was built with, merged with the defaults. */
12
45
  options;
13
- #e = null;
14
- #t = !1;
15
- #n = !1;
16
- #r;
17
- #i = null;
18
- #a = () => this.toggle();
19
- constructor(e, t = {}) {
20
- if (!(e instanceof HTMLElement)) throw TypeError("ReadMore: an HTMLElement is required.");
21
- if (n.has(e)) throw Error("ReadMore: element is already initialized. Call destroy() first.");
22
- if (this.el = e, this.options = {
23
- ...i,
24
- ...t
25
- }, this.options.height != null && this.options.height <= 0) throw RangeError("ReadMore: height must be greater than 0.");
26
- if (this.options.lines < 1) throw RangeError("ReadMore: lines must be at least 1.");
27
- this.#r = new ResizeObserver(() => this.#c()), n.set(e, this), this.#o();
28
- }
29
- static init(e, t) {
30
- let n = typeof e == "string" ? document.querySelectorAll(e) : e;
31
- return Array.from(n).map((e) => new a(e, t));
46
+ #button = null;
47
+ #expanded = false;
48
+ #generatedId = false;
49
+ #transientTimer = null;
50
+ #onClick = () => this.toggle();
51
+ get #destroyed() {
52
+ return instances.get(this.el) !== this;
53
+ }
54
+ /**
55
+ * @param element - The element to clamp.
56
+ * @param options - See {@link ReadMoreOptions}.
57
+ * @throws TypeError - if `element` is not an `HTMLElement`.
58
+ * @throws Error - if `element` is already initialized; call
59
+ * {@link ReadMore.destroy} first.
60
+ * @throws RangeError - if `lines` is not an integer greater than or equal
61
+ * to 1, or `height` is not a finite number greater than 0.
62
+ * @throws TypeError - if `buttonContainer` is set but is not an `HTMLElement`.
63
+ */
64
+ constructor(element, options = {}) {
65
+ if (!(element instanceof HTMLElement)) throw new TypeError("ReadMore: an HTMLElement is required.");
66
+ if (instances.has(element)) throw new Error("ReadMore: element is already initialized. Call destroy() first.");
67
+ this.el = element;
68
+ this.options = {
69
+ ...DEFAULTS,
70
+ ...Object.fromEntries(Object.entries(options).filter((entry) => entry[1] !== void 0))
71
+ };
72
+ for (const key of ["height", "lines"]) {
73
+ const value = this.options[key];
74
+ if (typeof value === "string" && value.trim() !== "") this.options[key] = Number(value);
75
+ }
76
+ const { height, lines, buttonContainer } = this.options;
77
+ if (buttonContainer != null && !(buttonContainer instanceof HTMLElement)) throw new TypeError("ReadMore: buttonContainer must be an HTMLElement.");
78
+ if (height != null && !(Number.isFinite(height) && height > 0)) throw new RangeError("ReadMore: height must be a finite number greater than 0.");
79
+ if (!(Number.isInteger(lines) && lines >= 1)) throw new RangeError("ReadMore: lines must be an integer of at least 1.");
80
+ instances.set(element, this);
81
+ this.#init();
82
+ }
83
+ /**
84
+ * Instantiates {@link ReadMore} on every matching element. Elements that are
85
+ * already initialized are left untouched and their existing instance is
86
+ * returned, so it is safe to call again after new content is added.
87
+ *
88
+ * @param target - A CSS selector, an element, or any iterable or array-like
89
+ * of elements (`NodeList`, `HTMLCollection`, array, `Set`…).
90
+ * @param options - See {@link ReadMoreOptions}. Not applied to elements
91
+ * that are already initialized.
92
+ * @param root - Where to look for `target` when it is a selector.
93
+ * @returns One instance per element, in document order.
94
+ */
95
+ static init(target, options, root = document) {
96
+ const nodes = typeof target === "string" ? root.querySelectorAll(target) : target instanceof HTMLElement ? [target] : target;
97
+ const batch = [];
98
+ ReadMore.#batch = batch;
99
+ try {
100
+ return Array.from(nodes).map((n) => instances.get(n) ?? new ReadMore(n, options));
101
+ } finally {
102
+ ReadMore.#batch = null;
103
+ ReadMore.#syncAll(batch);
104
+ }
32
105
  }
106
+ /** Whether the text is currently expanded. */
33
107
  get expanded() {
34
- return this.#t;
108
+ return this.#expanded;
35
109
  }
36
- toggle() {
37
- this.#t = !this.#t, this.el.classList.toggle(e, !this.#t), this.options.height != null && this.el.classList.toggle(t, !this.#t), this.el.classList.toggle(this.options.expandedClass, this.#t), this.#e && (this.#e.textContent = this.#t ? this.options.lessText : this.options.moreText, this.#e.setAttribute("aria-expanded", String(this.#t)));
38
- let n = this.#t ? this.options.openingClass : this.options.closingClass, r = this.#t ? this.options.closingClass : this.options.openingClass;
39
- this.el.classList.remove(r), this.#l(n), this.#t || this.#c(), this.options.onToggle?.(this.#t);
110
+ /**
111
+ * Expands or collapses the text, updating the button label, the
112
+ * `aria-expanded` attribute and the state classes, then calls
113
+ * {@link ReadMoreOptions.onToggle} and dispatches a bubbling
114
+ * `readmore:toggle` event on the element. Does nothing once destroyed.
115
+ *
116
+ * @param force - `true` to expand, `false` to collapse; the state is
117
+ * inverted when omitted. Does nothing if the text is already in the
118
+ * requested state.
119
+ */
120
+ toggle(force) {
121
+ const expanded = typeof force === "boolean" ? force : !this.#expanded;
122
+ if (this.#destroyed || expanded === this.#expanded) return;
123
+ this.#expanded = expanded;
124
+ this.el.classList.toggle(CLAMP_CLASS, !this.#expanded);
125
+ if (this.options.height != null) this.el.classList.toggle(CLAMP_HEIGHT_CLASS, !this.#expanded);
126
+ this.el.classList.toggle(this.options.expandedClass, this.#expanded);
127
+ this.#updateButton();
128
+ const stateClass = this.#expanded ? this.options.openingClass : this.options.closingClass;
129
+ const otherClass = this.#expanded ? this.options.closingClass : this.options.openingClass;
130
+ this.el.classList.remove(otherClass);
131
+ this.#applyTransientClass(stateClass);
132
+ try {
133
+ this.options.onToggle?.(this.#expanded, this);
134
+ } finally {
135
+ this.el.dispatchEvent(new CustomEvent("readmore:toggle", {
136
+ bubbles: true,
137
+ detail: {
138
+ expanded: this.#expanded,
139
+ instance: this
140
+ }
141
+ }));
142
+ }
143
+ }
144
+ /**
145
+ * Checks again whether the content overflows, mounting or unmounting the
146
+ * button. Resizes of the element are already tracked; call it after
147
+ * changing the content in a way that keeps the element's size. Does nothing
148
+ * while expanded, during a toggle transition, or once destroyed.
149
+ */
150
+ refresh() {
151
+ this.#sync(this.#isOverflowing());
40
152
  }
41
- static getInstance(e) {
42
- return n.get(e);
153
+ /**
154
+ * @param element - An element possibly initialized by this plugin.
155
+ * @returns The instance bound to it, or `undefined`.
156
+ */
157
+ static getInstance(element) {
158
+ return instances.get(element);
43
159
  }
160
+ /**
161
+ * Restores the element: removes the button, the classes, the CSS custom
162
+ * properties, the listeners and the generated `id`. The element can then be
163
+ * initialized again. Calling it again does nothing.
164
+ */
44
165
  destroy() {
45
- n.delete(this.el), this.#r.disconnect(), this.#i &&= (this.el.removeEventListener("transitionend", this.#i), null), this.#d(), this.el.classList.remove(e, t, this.options.expandedClass, this.options.openingClass, this.options.closingClass), this.el.style.removeProperty("--readmore-lines"), this.el.style.removeProperty("--readmore-height"), this.#n &&= (this.el.removeAttribute("id"), !1);
166
+ if (this.#destroyed) return;
167
+ instances.delete(this.el);
168
+ ReadMore.#resizeObserver?.unobserve(this.el);
169
+ if (this.#transientTimer !== null) {
170
+ clearTimeout(this.#transientTimer);
171
+ this.#transientTimer = null;
172
+ }
173
+ this.#unmountButton();
174
+ this.el.classList.remove(CLAMP_CLASS, CLAMP_HEIGHT_CLASS, this.options.expandedClass, this.options.openingClass, this.options.closingClass);
175
+ this.el.style.removeProperty("--readmore-lines");
176
+ this.el.style.removeProperty("--readmore-height");
177
+ if (this.#generatedId) {
178
+ this.el.removeAttribute("id");
179
+ this.#generatedId = false;
180
+ }
46
181
  }
47
- #o() {
48
- this.options.height == null ? (this.el.style.setProperty("--readmore-lines", String(this.options.lines)), this.el.classList.add(e)) : (this.el.style.setProperty("--readmore-height", `${this.options.height}px`), this.el.classList.add(e, t)), this.#r.observe(this.el), this.#c();
182
+ static #onResize(entries) {
183
+ ReadMore.#syncAll(entries.map((entry) => instances.get(entry.target)));
49
184
  }
50
- #s() {
51
- return this.el.scrollHeight - this.el.clientHeight > 1;
185
+ static #syncAll(list) {
186
+ const measured = /* @__PURE__ */ new Map();
187
+ for (const instance of list) if (instance) measured.set(instance, instance.#isOverflowing());
188
+ measured.forEach((overflowing, instance) => instance.#sync(overflowing));
52
189
  }
53
- #c() {
54
- if (this.#t) return;
55
- let e = this.#s();
56
- e && !this.#e ? this.#u() : !e && this.#e && this.#d();
190
+ #init() {
191
+ if (this.options.height != null) {
192
+ this.el.style.setProperty("--readmore-height", `${this.options.height}px`);
193
+ this.el.classList.add(CLAMP_CLASS, CLAMP_HEIGHT_CLASS);
194
+ } else {
195
+ this.el.style.setProperty("--readmore-lines", String(this.options.lines));
196
+ this.el.classList.add(CLAMP_CLASS);
197
+ }
198
+ ReadMore.#resizeObserver ??= new ResizeObserver(ReadMore.#onResize);
199
+ ReadMore.#resizeObserver.observe(this.el);
200
+ if (ReadMore.#batch) ReadMore.#batch.push(this);
201
+ else this.refresh();
202
+ this.#refreshOnFonts();
57
203
  }
58
- #l(e) {
59
- this.#i &&= (this.el.removeEventListener("transitionend", this.#i), null), this.el.classList.add(e);
60
- let t = () => this.el.classList.remove(e);
61
- if (!getComputedStyle(this.el).transitionDuration.split(",").some((e) => parseFloat(e) > 0)) {
62
- setTimeout(t, 0);
204
+ #refreshOnFonts() {
205
+ if (typeof document === "undefined" || !document.fonts) return;
206
+ if (ReadMore.#fontQueue) {
207
+ ReadMore.#fontQueue.push(this);
63
208
  return;
64
209
  }
65
- let n = (e) => {
66
- e.target === this.el && (this.el.removeEventListener("transitionend", n), this.#i = null, t());
67
- };
68
- this.#i = n, this.el.addEventListener("transitionend", n);
210
+ const queue = ReadMore.#fontQueue = [this];
211
+ document.fonts.ready.then(() => {
212
+ ReadMore.#fontQueue = null;
213
+ ReadMore.#syncAll(queue.filter((instance) => !instance.#destroyed));
214
+ });
215
+ }
216
+ #isOverflowing() {
217
+ return this.el.scrollHeight - this.el.clientHeight > 1;
218
+ }
219
+ #sync(overflowing) {
220
+ if (this.#expanded || this.#transientTimer !== null || this.#destroyed) return;
221
+ if (overflowing && !this.#button) this.#mountButton();
222
+ else if (!overflowing && this.#button) this.#unmountButton();
69
223
  }
70
- #u() {
71
- this.el.id || (this.el.id = `readmore-${++r}`, this.#n = !0);
72
- let e = document.createElement("button");
73
- e.type = "button", e.className = this.options.buttonClass, e.textContent = this.options.moreText, e.setAttribute("aria-expanded", "false"), e.setAttribute("aria-controls", this.el.id), e.addEventListener("click", this.#a), this.el.insertAdjacentElement("afterend", e), this.#e = e;
224
+ #applyTransientClass(stateClass) {
225
+ if (this.#transientTimer !== null) clearTimeout(this.#transientTimer);
226
+ this.el.classList.add(stateClass);
227
+ const timer = setTimeout(() => {
228
+ const running = this.el.getAnimations?.().filter((a) => a instanceof CSSTransition) ?? [];
229
+ const done = () => {
230
+ if (this.#transientTimer !== timer) return;
231
+ this.#transientTimer = null;
232
+ this.el.classList.remove(stateClass);
233
+ this.refresh();
234
+ };
235
+ if (running.length) Promise.allSettled(running.map((a) => a.finished)).then(done);
236
+ else done();
237
+ }, this.#transitionTime());
238
+ this.#transientTimer = timer;
74
239
  }
75
- #d() {
76
- this.#e &&= (this.#e.removeEventListener("click", this.#a), this.#e.remove(), null);
240
+ /** Longest `duration + delay` among the transitions declared on the element, in ms. */
241
+ #transitionTime() {
242
+ const style = getComputedStyle(this.el);
243
+ const durations = toMs(style.transitionDuration);
244
+ const delays = toMs(style.transitionDelay);
245
+ let max = 0;
246
+ for (let i = 0; i < Math.max(durations.length, delays.length); i++) max = Math.max(max, durations[i % durations.length] + delays[i % delays.length]);
247
+ return max;
248
+ }
249
+ #mountButton() {
250
+ if (!this.el.id) {
251
+ let id;
252
+ do
253
+ id = `readmore-${++idCounter}`;
254
+ while (document.getElementById(id));
255
+ this.el.id = id;
256
+ this.#generatedId = true;
257
+ }
258
+ const btn = document.createElement("button");
259
+ btn.type = "button";
260
+ btn.className = this.options.buttonClass;
261
+ btn.setAttribute("aria-controls", this.el.id);
262
+ btn.addEventListener("click", this.#onClick);
263
+ this.#button = btn;
264
+ this.#updateButton();
265
+ if (this.options.buttonContainer) this.options.buttonContainer.appendChild(btn);
266
+ else this.el.insertAdjacentElement("afterend", btn);
267
+ }
268
+ /** Syncs the button's text, `aria-expanded` and `aria-label` with the state. */
269
+ #updateButton() {
270
+ if (!this.#button) return;
271
+ const { moreText, lessText, moreLabel, lessLabel } = this.options;
272
+ const label = this.#expanded ? lessLabel : moreLabel;
273
+ this.#button.textContent = this.#expanded ? lessText : moreText;
274
+ this.#button.setAttribute("aria-expanded", String(this.#expanded));
275
+ if (label) this.#button.setAttribute("aria-label", label);
276
+ else this.#button.removeAttribute("aria-label");
277
+ }
278
+ #unmountButton() {
279
+ if (!this.#button) return;
280
+ this.#button.removeEventListener("click", this.#onClick);
281
+ if (document.activeElement === this.#button) {
282
+ const { el } = this;
283
+ if (!el.hasAttribute("tabindex")) {
284
+ el.tabIndex = -1;
285
+ el.addEventListener("blur", () => el.removeAttribute("tabindex"), { once: true });
286
+ }
287
+ el.focus();
288
+ }
289
+ this.#button.remove();
290
+ this.#button = null;
77
291
  }
78
292
  };
79
- //#endregion
80
- export { a as ReadMore };
293
+
294
+ export { ReadMore };
295
+ //# sourceMappingURL=readmore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readmore.js","names":[],"sources":["../src/readmore.ts"],"sourcesContent":["/**\n * Options accepted by {@link ReadMore}. Every field is optional.\n */\nexport interface ReadMoreOptions {\n /**\n * Number of lines to clamp to. Ignored when {@link ReadMoreOptions.height} is set.\n *\n * @defaultValue 3\n * @throws RangeError - if not an integer greater than or equal to 1.\n */\n lines?: number;\n\n /**\n * Max height in pixels. Takes precedence over {@link ReadMoreOptions.lines}\n * and clamps with `max-height` instead of `-webkit-line-clamp`.\n *\n * @throws RangeError - if not a finite number greater than 0.\n */\n height?: number;\n\n /**\n * Label of the toggle button while the text is collapsed.\n *\n * @defaultValue 'Read more'\n */\n moreText?: string;\n\n /**\n * Label of the toggle button while the text is expanded.\n *\n * @defaultValue 'Read less'\n */\n lessText?: string;\n\n /**\n * Accessible name (`aria-label`) of the toggle button while the text is\n * collapsed, e.g. `'Read more about Product A'` when several buttons share\n * the same visible text. It should contain {@link ReadMoreOptions.moreText}.\n */\n moreLabel?: string;\n\n /**\n * Accessible name (`aria-label`) of the toggle button while the text is\n * expanded. It should contain {@link ReadMoreOptions.lessText}.\n */\n lessLabel?: string;\n\n /**\n * Class applied to the toggle button.\n *\n * @defaultValue 'readmore-btn'\n */\n buttonClass?: string;\n\n /**\n * Element the toggle button is appended to, instead of being inserted right\n * after the clamped element. Use an element owned by your framework (a ref)\n * so reconciliation cannot drop the button.\n *\n * @throws TypeError - if not an `HTMLElement`.\n */\n buttonContainer?: HTMLElement;\n\n /**\n * Class applied to the target element for the whole expanded state.\n *\n * @defaultValue 'is-expanded'\n */\n expandedClass?: string;\n\n /**\n * Transient class applied when expanding, removed once the longest CSS\n * transition on the element (duration + delay) has elapsed — or on the next\n * tick if no transition is declared.\n *\n * @defaultValue 'is-opening'\n */\n openingClass?: string;\n\n /**\n * Transient class applied when collapsing, removed once the longest CSS\n * transition on the element (duration + delay) has elapsed — or on the next\n * tick if no transition is declared.\n *\n * @defaultValue 'is-closing'\n */\n closingClass?: string;\n\n /**\n * Called after each toggle with the resulting state, right before the\n * `readmore:toggle` event is dispatched.\n *\n * @param expanded - `true` once expanded, `false` once collapsed.\n * @param instance - The instance that was toggled.\n */\n onToggle?: (expanded: boolean, instance: ReadMore) => void;\n}\n\ntype OptionalKeys = 'height' | 'buttonContainer' | 'moreLabel' | 'lessLabel' | 'onToggle';\n\n/**\n * {@link ReadMoreOptions} merged with the defaults, as exposed on\n * {@link ReadMore.options}.\n */\nexport type ResolvedOptions = Required<Omit<ReadMoreOptions, OptionalKeys>> & Pick<ReadMoreOptions, OptionalKeys>;\n\n/** `detail` of the `readmore:toggle` event. */\nexport interface ReadMoreToggleDetail {\n /** `true` once expanded, `false` once collapsed. */\n expanded: boolean;\n\n /** The instance that was toggled. */\n instance: ReadMore;\n}\n\n// Types `event.detail` in `addEventListener('readmore:toggle', …)`, on elements\n// and on `document` (the event bubbles).\ndeclare global {\n interface HTMLElementEventMap {\n 'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;\n }\n\n interface DocumentEventMap {\n 'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;\n }\n}\n\nconst CLAMP_CLASS = 'readmore-clamp';\nconst CLAMP_HEIGHT_CLASS = 'readmore-clamp--height';\n\nconst instances = new WeakMap<HTMLElement, ReadMore>();\nlet idCounter = 0;\n\nconst DEFAULTS: ResolvedOptions = {\n lines: 3,\n moreText: 'Read more',\n lessText: 'Read less',\n buttonClass: 'readmore-btn',\n expandedClass: 'is-expanded',\n openingClass: 'is-opening',\n closingClass: 'is-closing'\n};\n\n/**\n * Parses a computed `transition-duration` / `transition-delay` list into\n * milliseconds. Computed times are always serialized in seconds.\n */\nfunction toMs(list: string): number[] {\n return list.split(',').map((v) => parseFloat(v) * 1000 || 0);\n}\n\n/**\n * Clamps an element to a number of lines (or a fixed height) and mounts a\n * `Read more` / `Read less` button right after it, but only while the content\n * actually overflows.\n *\n * @example\n * ```ts\n * import { ReadMore } from '@frontfactory/readmore';\n * import '@frontfactory/readmore/style.css';\n *\n * ReadMore.init('.excerpt', { lines: 3 });\n * ```\n */\nexport class ReadMore {\n /** Shared by every instance, created with the first one. */\n static #resizeObserver: ResizeObserver | undefined;\n\n /** Instances created by the running {@link ReadMore.init}, synced together once all exist. */\n static #batch: ReadMore[] | null = null;\n\n /** Instances waiting for `document.fonts.ready`, measured together. */\n static #fontQueue: ReadMore[] | null = null;\n\n /** The clamped element. */\n readonly el: HTMLElement;\n\n /** The options this instance was built with, merged with the defaults. */\n readonly options: Readonly<ResolvedOptions>;\n\n #button: HTMLButtonElement | null = null;\n #expanded = false;\n #generatedId = false;\n #transientTimer: ReturnType<typeof setTimeout> | null = null;\n #onClick = (): void => this.toggle();\n\n // Destroyed once the element is no longer bound to this instance: after\n // destroy(), or if the element was re-initialized by another instance.\n get #destroyed(): boolean {\n return instances.get(this.el) !== this;\n }\n\n /**\n * @param element - The element to clamp.\n * @param options - See {@link ReadMoreOptions}.\n * @throws TypeError - if `element` is not an `HTMLElement`.\n * @throws Error - if `element` is already initialized; call\n * {@link ReadMore.destroy} first.\n * @throws RangeError - if `lines` is not an integer greater than or equal\n * to 1, or `height` is not a finite number greater than 0.\n * @throws TypeError - if `buttonContainer` is set but is not an `HTMLElement`.\n */\n constructor(element: HTMLElement, options: ReadMoreOptions = {}) {\n if (!(element instanceof HTMLElement)) {\n throw new TypeError('ReadMore: an HTMLElement is required.');\n }\n\n if (instances.has(element)) {\n throw new Error('ReadMore: element is already initialized. Call destroy() first.');\n }\n\n this.el = element;\n // Options explicitly set to `undefined` must not override the defaults.\n this.options = {\n ...DEFAULTS,\n ...Object.fromEntries(Object.entries(options).filter((entry) => entry[1] !== undefined))\n };\n\n // Numeric strings (data-* attributes, framework props) are accepted; blank or non-numeric ones still fail below.\n for (const key of [\n 'height',\n 'lines'\n ] as const) {\n const value: unknown = this.options[key];\n\n if (typeof value === 'string' && value.trim() !== '') {\n (this.options as Record<string, unknown>)[key] = Number(value);\n }\n }\n\n const { height, lines, buttonContainer } = this.options;\n\n if (buttonContainer != null && !(buttonContainer instanceof HTMLElement)) {\n throw new TypeError('ReadMore: buttonContainer must be an HTMLElement.');\n }\n\n if (height != null && !(Number.isFinite(height) && height > 0)) {\n throw new RangeError('ReadMore: height must be a finite number greater than 0.');\n }\n\n if (!(Number.isInteger(lines) && lines >= 1)) {\n throw new RangeError('ReadMore: lines must be an integer of at least 1.');\n }\n\n instances.set(element, this);\n this.#init();\n }\n\n /**\n * Instantiates {@link ReadMore} on every matching element. Elements that are\n * already initialized are left untouched and their existing instance is\n * returned, so it is safe to call again after new content is added.\n *\n * @param target - A CSS selector, an element, or any iterable or array-like\n * of elements (`NodeList`, `HTMLCollection`, array, `Set`…).\n * @param options - See {@link ReadMoreOptions}. Not applied to elements\n * that are already initialized.\n * @param root - Where to look for `target` when it is a selector.\n * @returns One instance per element, in document order.\n */\n static init(\n target: string | HTMLElement | Iterable<HTMLElement> | ArrayLike<HTMLElement>,\n options?: ReadMoreOptions,\n root: ParentNode = document\n ): ReadMore[] {\n const nodes =\n typeof target === 'string'\n ? root.querySelectorAll<HTMLElement>(target)\n : target instanceof HTMLElement ? [\n target\n ] : target;\n\n const batch: ReadMore[] = [];\n\n ReadMore.#batch = batch;\n\n try {\n return Array.from(nodes).map((n) => instances.get(n) ?? new ReadMore(n, options));\n } finally {\n ReadMore.#batch = null;\n ReadMore.#syncAll(batch);\n }\n }\n\n /** Whether the text is currently expanded. */\n get expanded(): boolean {\n return this.#expanded;\n }\n\n /**\n * Expands or collapses the text, updating the button label, the\n * `aria-expanded` attribute and the state classes, then calls\n * {@link ReadMoreOptions.onToggle} and dispatches a bubbling\n * `readmore:toggle` event on the element. Does nothing once destroyed.\n *\n * @param force - `true` to expand, `false` to collapse; the state is\n * inverted when omitted. Does nothing if the text is already in the\n * requested state.\n */\n toggle(force?: boolean): void {\n // Only a real boolean forces the state, so `toggle` still works as an\n // event listener (`addEventListener('click', rm.toggle.bind(rm))`).\n const expanded = typeof force === 'boolean' ? force : !this.#expanded;\n\n if (this.#destroyed || expanded === this.#expanded) {\n return;\n }\n\n this.#expanded = expanded;\n this.el.classList.toggle(CLAMP_CLASS, !this.#expanded);\n\n if (this.options.height != null) {\n this.el.classList.toggle(CLAMP_HEIGHT_CLASS, !this.#expanded);\n }\n\n this.el.classList.toggle(this.options.expandedClass, this.#expanded);\n this.#updateButton();\n\n const stateClass = this.#expanded ? this.options.openingClass : this.options.closingClass;\n const otherClass = this.#expanded ? this.options.closingClass : this.options.openingClass;\n\n this.el.classList.remove(otherClass);\n this.#applyTransientClass(stateClass);\n\n try {\n this.options.onToggle?.(this.#expanded, this);\n } finally {\n // The event is dispatched even if `onToggle` throws (the error still propagates).\n this.el.dispatchEvent(new CustomEvent<ReadMoreToggleDetail>('readmore:toggle', {\n bubbles: true,\n detail: {\n expanded: this.#expanded,\n instance: this\n }\n }));\n }\n }\n\n /**\n * Checks again whether the content overflows, mounting or unmounting the\n * button. Resizes of the element are already tracked; call it after\n * changing the content in a way that keeps the element's size. Does nothing\n * while expanded, during a toggle transition, or once destroyed.\n */\n refresh(): void {\n this.#sync(this.#isOverflowing());\n }\n\n /**\n * @param element - An element possibly initialized by this plugin.\n * @returns The instance bound to it, or `undefined`.\n */\n static getInstance(element: HTMLElement): ReadMore | undefined {\n return instances.get(element);\n }\n\n /**\n * Restores the element: removes the button, the classes, the CSS custom\n * properties, the listeners and the generated `id`. The element can then be\n * initialized again. Calling it again does nothing.\n */\n destroy(): void {\n if (this.#destroyed) {\n return;\n }\n\n instances.delete(this.el);\n ReadMore.#resizeObserver?.unobserve(this.el);\n\n if (this.#transientTimer !== null) {\n clearTimeout(this.#transientTimer);\n this.#transientTimer = null;\n }\n\n this.#unmountButton();\n this.el.classList.remove(\n CLAMP_CLASS,\n CLAMP_HEIGHT_CLASS,\n this.options.expandedClass,\n this.options.openingClass,\n this.options.closingClass\n );\n\n this.el.style.removeProperty('--readmore-lines');\n this.el.style.removeProperty('--readmore-height');\n\n if (this.#generatedId) {\n this.el.removeAttribute('id');\n this.#generatedId = false;\n }\n }\n\n // Measures every resized element before touching the DOM, so mounting a\n // button does not force a new layout for each following element.\n static #onResize(entries: ResizeObserverEntry[]): void {\n ReadMore.#syncAll(entries.map((entry) => instances.get(entry.target as HTMLElement)));\n }\n\n static #syncAll(list: (ReadMore | undefined)[]): void {\n const measured = new Map<ReadMore, boolean>();\n\n for (const instance of list) {\n if (instance) {\n measured.set(instance, instance.#isOverflowing());\n }\n }\n\n measured.forEach((overflowing, instance) => instance.#sync(overflowing));\n }\n\n #init(): void {\n if (this.options.height != null) {\n this.el.style.setProperty('--readmore-height', `${ this.options.height }px`);\n this.el.classList.add(CLAMP_CLASS, CLAMP_HEIGHT_CLASS);\n } else {\n this.el.style.setProperty('--readmore-lines', String(this.options.lines));\n this.el.classList.add(CLAMP_CLASS);\n }\n\n ReadMore.#resizeObserver ??= new ResizeObserver(ReadMore.#onResize);\n ReadMore.#resizeObserver.observe(this.el);\n\n // Inside init(), the batch measures all elements at once afterwards.\n if (ReadMore.#batch) {\n ReadMore.#batch.push(this);\n } else {\n this.refresh();\n }\n\n this.#refreshOnFonts();\n }\n\n // A font swap can change the overflow without resizing the element, which the\n // ResizeObserver would miss. Instances created in the same tick share one\n // measurement; destroyed ones are skipped by #sync.\n #refreshOnFonts(): void {\n if (typeof document === 'undefined' || !document.fonts) {\n return;\n }\n\n if (ReadMore.#fontQueue) {\n ReadMore.#fontQueue.push(this);\n\n return;\n }\n\n const queue: ReadMore[] = ReadMore.#fontQueue = [\n this\n ];\n\n void document.fonts.ready.then(() => {\n ReadMore.#fontQueue = null;\n ReadMore.#syncAll(queue.filter((instance) => !instance.#destroyed));\n });\n }\n\n #isOverflowing(): boolean {\n return this.el.scrollHeight - this.el.clientHeight > 1;\n }\n\n #sync(overflowing: boolean): void {\n // While a toggle transition runs, the element's size is not final: measuring\n // it mid-collapse would remove the button. The timer refreshes once it ends.\n if (this.#expanded || this.#transientTimer !== null || this.#destroyed) {\n return;\n }\n\n if (overflowing && !this.#button) {\n this.#mountButton();\n } else if (!overflowing && this.#button) {\n this.#unmountButton();\n }\n }\n\n #applyTransientClass(stateClass: string): void {\n if (this.#transientTimer !== null) {\n clearTimeout(this.#transientTimer);\n }\n\n this.el.classList.add(stateClass);\n\n // A timer rather than `transitionend`, which never fires when no declared\n // property actually changes (e.g. `transition: all` in lines mode).\n const timer = setTimeout(() => {\n // Some engines (WebKit) start the transition later than the timer assumes:\n // wait for it to end, or the size is measured mid-way and the button remounted.\n const running = this.el.getAnimations?.().filter(a => a instanceof CSSTransition) ?? [];\n const done = () => {\n // Superseded by another toggle, or destroyed meanwhile.\n if (this.#transientTimer !== timer) {\n return;\n }\n\n this.#transientTimer = null;\n this.el.classList.remove(stateClass);\n this.refresh();\n };\n\n if (running.length) {\n Promise.allSettled(running.map(a => a.finished)).then(done);\n } else {\n done();\n }\n }, this.#transitionTime());\n\n this.#transientTimer = timer;\n }\n\n /** Longest `duration + delay` among the transitions declared on the element, in ms. */\n #transitionTime(): number {\n const style = getComputedStyle(this.el);\n const durations = toMs(style.transitionDuration);\n const delays = toMs(style.transitionDelay);\n let max = 0;\n\n // Shorter lists repeat to match the longer one, as CSS does.\n for (let i = 0; i < Math.max(durations.length, delays.length); i++) {\n max = Math.max(max, durations[i % durations.length] + delays[i % delays.length]);\n }\n\n return max;\n }\n\n #mountButton(): void {\n if (!this.el.id) {\n let id: string;\n\n // Skip ids already used in the page (server-rendered markup, another copy of the plugin…).\n do {\n id = `readmore-${++idCounter}`;\n } while (document.getElementById(id));\n\n this.el.id = id;\n this.#generatedId = true;\n }\n\n const btn = document.createElement('button');\n\n btn.type = 'button';\n btn.className = this.options.buttonClass;\n btn.setAttribute('aria-controls', this.el.id);\n btn.addEventListener('click', this.#onClick);\n this.#button = btn;\n this.#updateButton();\n\n if (this.options.buttonContainer) {\n this.options.buttonContainer.appendChild(btn);\n } else {\n this.el.insertAdjacentElement('afterend', btn);\n }\n }\n\n /** Syncs the button's text, `aria-expanded` and `aria-label` with the state. */\n #updateButton(): void {\n if (!this.#button) {\n return;\n }\n\n const { moreText, lessText, moreLabel, lessLabel } = this.options;\n const label = this.#expanded ? lessLabel : moreLabel;\n\n this.#button.textContent = this.#expanded ? lessText : moreText;\n this.#button.setAttribute('aria-expanded', String(this.#expanded));\n\n if (label) {\n this.#button.setAttribute('aria-label', label);\n } else {\n this.#button.removeAttribute('aria-label');\n }\n }\n\n #unmountButton(): void {\n if (!this.#button) {\n return;\n }\n\n this.#button.removeEventListener('click', this.#onClick);\n\n // Removing the focused button would drop focus to <body>: keep it on the element.\n if (document.activeElement === this.#button) {\n const { el } = this;\n\n if (!el.hasAttribute('tabindex')) {\n el.tabIndex = -1;\n el.addEventListener('blur', () => el.removeAttribute('tabindex'), {\n once: true\n });\n }\n\n el.focus();\n }\n\n this.#button.remove();\n this.#button = null;\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,26 +1,33 @@
1
1
  {
2
2
  "name": "@frontfactory/readmore",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "description": "Zero-dependency plugin to truncate text to N lines or a fixed height, with an ellipsis and an accessible read more / read less toggle.",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
8
+ "lint": "eslint .",
8
9
  "lint:js": "eslint --fix .",
9
10
  "build": "vite build",
10
11
  "preview": "vite preview",
12
+ "docs:dev": "vitepress dev docs",
13
+ "docs:build": "vitepress build docs",
14
+ "docs:preview": "vitepress preview docs",
11
15
  "typecheck": "tsc --noEmit",
12
- "test": "vitest run",
16
+ "test": "vitest run --project unit",
17
+ "test:browser": "vitest run --project browser --outputFile.junit=junit-browser.xml",
13
18
  "test:watch": "vitest",
14
- "test:coverage": "vitest run --coverage",
19
+ "test:coverage": "vitest run --project unit --coverage",
15
20
  "prepare": "husky",
16
21
  "prepack": "npm run build",
17
22
  "release": "semantic-release"
18
23
  },
19
24
  "engines": {
20
- "node": ">=20"
25
+ "node": "^22.22.2 || >=24.22"
21
26
  },
22
27
  "main": "./dist/readmore.js",
23
28
  "types": "./dist/readmore.d.ts",
29
+ "unpkg": "./dist/readmore.iife.js",
30
+ "jsdelivr": "./dist/readmore.iife.js",
24
31
  "exports": {
25
32
  ".": {
26
33
  "types": "./dist/readmore.d.ts",
@@ -31,25 +38,31 @@
31
38
  "./package.json": "./package.json"
32
39
  },
33
40
  "devDependencies": {
34
- "@commitlint/cli": "^20.5.3",
35
- "@commitlint/config-conventional": "^20.5.3",
36
- "@front-factory/eslint-config": "^2.4.2",
37
- "@semantic-release/changelog": "^6.0.3",
38
- "@semantic-release/git": "^10.0.1",
39
- "@semantic-release/github": "^12.0.6",
40
- "@vitest/coverage-v8": "^4.1.5",
41
+ "@commitlint/cli": "^21.2.4",
42
+ "@commitlint/config-conventional": "^21.2.4",
43
+ "@front-factory/eslint-config": "^3.0.0",
44
+ "@frontfactory/vitepress-theme": "^1.7.0",
45
+ "@semantic-release/changelog": "^7.0.0",
46
+ "@semantic-release/git": "^11.0.1",
47
+ "@semantic-release/github": "^12.0.10",
48
+ "@types/node": "^24.19.2",
49
+ "@vitest/browser-playwright": "^5.0.5",
50
+ "@vitest/coverage-v8": "^5.0.5",
41
51
  "conventional-changelog-conventionalcommits": "^9.3.1",
42
- "eslint": "^10.3.0",
52
+ "eslint": "^10.13.0",
43
53
  "husky": "^9.1.7",
44
- "jsdom": "^29.1.1",
45
- "lightningcss": "^1.32.0",
46
- "semantic-release": "^25.0.3",
47
- "typescript": "^5.4.0",
48
- "typescript-eslint": "^8.59.2",
49
- "vite": "^8.0.11",
50
- "vite-plugin-dts": "^5.0.0",
51
- "vite-plugin-static-copy": "^4.1.0",
52
- "vitest": "^4.1.5"
54
+ "jsdom": "^30.1.3",
55
+ "lightningcss": "^1.33.0",
56
+ "playwright": "^1.64.1",
57
+ "semantic-release": "^25.0.9",
58
+ "typescript": "^6.0.3",
59
+ "typescript-eslint": "^8.72.0",
60
+ "vite": "^8.3.5",
61
+ "vite-plugin-dts": "^5.1.2",
62
+ "vite-plugin-static-copy": "^4.1.1",
63
+ "vitepress": "^2.0.0-alpha.21",
64
+ "vitest": "^5.0.5",
65
+ "vue": "^3.5.44"
53
66
  },
54
67
  "keywords": [
55
68
  "readmore",
File without changes