@frontfactory/readmore 1.6.0 → 1.7.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
@@ -102,6 +102,18 @@ document.addEventListener('readmore:toggle', (event) => {
102
102
  });
103
103
  ```
104
104
 
105
+ To veto a toggle, return `false` from the `onBeforeToggle` option, or call `preventDefault()` on the cancelable
106
+ `readmore:beforetoggle` event. Both receive the state about to be entered (`expanded`); the callback runs first, and the
107
+ event is not dispatched if it returned `false`. A cancelled toggle changes nothing and fires neither `onToggle` nor
108
+ `readmore:toggle`. If `onBeforeToggle` throws, the error propagates and the toggle does not happen. It only runs for
109
+ toggles that would really happen (no-ops are skipped), including the automatic expansion on focus.
110
+
111
+ ```ts
112
+ document.addEventListener('readmore:beforetoggle', (event) => {
113
+ if (event.detail.expanded && !userMayExpand) event.preventDefault();
114
+ });
115
+ ```
116
+
105
117
  Pass `true` or `false` to `toggle()` to force a state, e.g. for an "Expand all" button. Instances already in that
106
118
  state are left untouched and fire neither `onToggle` nor the event:
107
119
 
@@ -145,21 +157,23 @@ return (
145
157
 
146
158
  ## Options
147
159
 
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
- | `scrollOnCollapse` | `boolean` | `false` | On collapse, scroll the element back into view if its top ended up above the viewport. |
162
- | `onToggle` | `(expanded: boolean, instance: ReadMore) => void` | — | Callback fired after each toggle, before the `readmore:toggle` event (still dispatched if it throws). |
160
+ | Option | Type | Default | Description |
161
+ |--------------------|--------------------------------------------------------------|------------------|----------------------------------------------------------------------------------------------------------------|
162
+ | `lines` | `number \| (el) => number` | `3` | Number of lines to clamp to (min `1`). Ignored if `height` is set. A function is called with the element. |
163
+ | `height` | `number \| (el) => number` | — | Max height in pixels (greater than `0`). Takes precedence over `lines`. A function is called with the element. |
164
+ | `threshold` | `number` | `0` | Hidden overflow in pixels tolerated before the button is mounted (min `0`; at least 1px is always required). |
165
+ | `moreText` | `string` | `'Read more'` | Label of the button when the text is collapsed. |
166
+ | `lessText` | `string` | `'Read less'` | Label of the button when the text is expanded. |
167
+ | `moreLabel` | `string` | — | `aria-label` of the button when collapsed. Should contain `moreText`. |
168
+ | `lessLabel` | `string` | — | `aria-label` of the button when expanded. Should contain `lessText`. |
169
+ | `buttonClass` | `string` | `'readmore-btn'` | Class applied to the toggle button. |
170
+ | `buttonContainer` | `HTMLElement` | — | Element the button is appended to, instead of being inserted after the text. |
171
+ | `expandedClass` | `string` | `'is-expanded'` | Class applied to the target element while expanded. |
172
+ | `openingClass` | `string` | `'is-opening'` | Transient class applied while expanding, removed after transition. |
173
+ | `closingClass` | `string` | `'is-closing'` | Transient class applied while collapsing, removed after transition. |
174
+ | `scrollOnCollapse` | `boolean` | `false` | On collapse, scroll the element back into view if its top ended up above the viewport. |
175
+ | `onBeforeToggle` | `(expanded: boolean, instance: ReadMore) => boolean \| void` | — | Called before a toggle with the state about to be entered; returning `false` cancels it. |
176
+ | `onToggle` | `(expanded: boolean, instance: ReadMore) => void` | — | Callback fired after each toggle, before the `readmore:toggle` event (still dispatched if it throws). |
163
177
 
164
178
  For `expandedClass`, `openingClass` and `closingClass`, an empty string falls back to the default.
165
179
 
@@ -186,12 +200,12 @@ ReadMore.getInstance(element); // returns the ReadMore bound to an eleme
186
200
 
187
201
  The constructor throws instead of failing silently:
188
202
 
189
- | Error | When |
190
- |--------------|-------------------------------------------------------------------------------------------|
191
- | `TypeError` | `element` is not an `HTMLElement`. |
192
- | `Error` | The element is already initialized — call `destroy()` before re-initializing. |
193
- | `RangeError` | `lines` is not an integer `>= 1`, or `height` is not a finite number `> 0`. |
194
- | `RangeError` | `expandedClass`, `openingClass` or `closingClass` is not a string or contains whitespace. |
203
+ | Error | When |
204
+ |--------------|------------------------------------------------------------------------------------------------------------------------|
205
+ | `TypeError` | `element` is not an `HTMLElement`. |
206
+ | `Error` | The element is already initialized — call `destroy()` before re-initializing. |
207
+ | `RangeError` | `lines` is not an integer `>= 1`, `height` is not a finite number `> 0`, or `threshold` is not a finite number `>= 0`. |
208
+ | `RangeError` | `expandedClass`, `openingClass` or `closingClass` is not a string or contains whitespace. |
195
209
 
196
210
  A `buttonContainer` that is not an `HTMLElement` also throws a `TypeError`.
197
211
 
@@ -200,9 +214,23 @@ A `buttonContainer` that is not an `HTMLElement` also throws a `TypeError`.
200
214
  Importing the module is safe without a browser. There, `ReadMore.init()` is a no-op that returns `[]`, and the
201
215
  constructor throws a `TypeError` (no `HTMLElement`).
202
216
 
203
- `lines` and `height` also accept numeric strings such as `'3'` (e.g. read from a `data-*` attribute); blank or
217
+ `lines`, `height` and `threshold` also accept numeric strings such as `'3'` (e.g. read from a `data-*` attribute); blank or
204
218
  non-numeric strings throw a `RangeError`.
205
219
 
220
+ `lines` and `height` can also be a function, called with the element, whose returned value is validated like a literal
221
+ one (a `RangeError` if invalid). It runs at construction and on each `refresh()`, not on every resize: call
222
+ `refresh()` from your own breakpoint listener to re-evaluate it.
223
+
224
+ ```js
225
+ ReadMore.init('.excerpt', {
226
+ lines: (el) => (window.innerWidth < 600 ? 2 : Number(el.dataset.lines) || 4)
227
+ });
228
+
229
+ matchMedia('(max-width: 600px)').addEventListener('change', () => {
230
+ document.querySelectorAll('.excerpt').forEach((el) => ReadMore.getInstance(el)?.refresh());
231
+ });
232
+ ```
233
+
206
234
  ## Accessibility
207
235
 
208
236
  When the button is mounted, it gets `aria-expanded` (kept in sync on every toggle) and `aria-controls`
@@ -6,16 +6,29 @@ 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
+ * Can also be a function called with the element; the returned value is
10
+ * validated like a literal one and re-evaluated on {@link ReadMore.refresh}.
11
+ *
9
12
  * @throws RangeError - if not an integer greater than or equal to 1.
10
13
  */
11
- lines?: number;
14
+ lines?: number | ((el: HTMLElement) => number);
12
15
  /**
13
16
  * Max height in pixels. Takes precedence over {@link ReadMoreOptions.lines}
14
17
  * and clamps with `max-height` instead of `-webkit-line-clamp`.
18
+ * Can also be a function called with the element; the returned value is
19
+ * validated like a literal one and re-evaluated on {@link ReadMore.refresh}.
15
20
  *
16
21
  * @throws RangeError - if not a finite number greater than 0.
17
22
  */
18
- height?: number;
23
+ height?: number | ((el: HTMLElement) => number);
24
+ /**
25
+ * Hidden overflow in pixels tolerated before the button is mounted: the
26
+ * button only appears when more than this is hidden (at least 1px).
27
+ *
28
+ * @defaultValue 0
29
+ * @throws RangeError - if not a finite number greater than or equal to 0.
30
+ */
31
+ threshold?: number;
19
32
  /**
20
33
  * Label of the toggle button while the text is collapsed.
21
34
  *
@@ -90,8 +103,18 @@ export interface ReadMoreOptions {
90
103
  * @param instance - The instance that was toggled.
91
104
  */
92
105
  onToggle?: (expanded: boolean, instance: ReadMore) => void;
106
+ /**
107
+ * Called before a toggle that would really happen, with the state it is
108
+ * about to enter. Returning exactly `false` cancels it (the
109
+ * `readmore:beforetoggle` event is then not dispatched). If it throws, the
110
+ * error propagates and the toggle does not happen.
111
+ *
112
+ * @param expanded - `true` if about to expand, `false` if about to collapse.
113
+ * @param instance - The instance about to be toggled.
114
+ */
115
+ onBeforeToggle?: (expanded: boolean, instance: ReadMore) => boolean | void;
93
116
  }
94
- type OptionalKeys = 'height' | 'buttonContainer' | 'moreLabel' | 'lessLabel' | 'onToggle';
117
+ type OptionalKeys = 'height' | 'buttonContainer' | 'moreLabel' | 'lessLabel' | 'onToggle' | 'onBeforeToggle';
95
118
  /**
96
119
  * {@link ReadMoreOptions} merged with the defaults, as exposed on
97
120
  * {@link ReadMore.options}.
@@ -107,9 +130,11 @@ export interface ReadMoreToggleDetail {
107
130
  declare global {
108
131
  interface HTMLElementEventMap {
109
132
  'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;
133
+ 'readmore:beforetoggle': CustomEvent<ReadMoreToggleDetail>;
110
134
  }
111
135
  interface DocumentEventMap {
112
136
  'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;
137
+ 'readmore:beforetoggle': CustomEvent<ReadMoreToggleDetail>;
113
138
  }
114
139
  }
115
140
  /**
@@ -140,7 +165,9 @@ export declare class ReadMore {
140
165
  * @throws Error - if `element` is already initialized; call
141
166
  * {@link ReadMore.destroy} first.
142
167
  * @throws RangeError - if `lines` is not an integer greater than or equal
143
- * to 1, or `height` is not a finite number greater than 0.
168
+ * to 1, `height` is not a finite number greater than 0, or `threshold` is
169
+ * not a finite number greater than or equal to 0 (for `lines` and `height`,
170
+ * the value returned by a function is checked).
144
171
  * @throws RangeError - if `expandedClass`, `openingClass` or `closingClass`
145
172
  * is not a string or contains whitespace (an empty string means the default).
146
173
  * @throws TypeError - if `buttonContainer` is set but is not an `HTMLElement`.
@@ -170,7 +197,8 @@ export declare class ReadMore {
170
197
  *
171
198
  * @param force - `true` to expand, `false` to collapse; the state is
172
199
  * inverted when omitted. Does nothing if the text is already in the
173
- * requested state.
200
+ * requested state. Before a real toggle, {@link ReadMoreOptions.onBeforeToggle}
201
+ * and a cancelable `readmore:beforetoggle` event can cancel it.
174
202
  */
175
203
  toggle(force?: boolean): void;
176
204
  /**
@@ -1,2 +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`,scrollOnCollapse:!1};function o(e){return e.split(`,`).map(e=>parseFloat(e)*1e3||0)}e.ReadMore=class e{static#e;static#t=null;static#n=null;static#r=/* @__PURE__ */ new Set;static#i;el;options;#a=null;#o=!1;#s=!1;#c=null;#l=()=>this.toggle();#u=e=>{let{target:t}=e;this.#o||!this.#a||t===this.el||!(t instanceof Element)||t.getBoundingClientRect().bottom+this.el.scrollTop>this.el.getBoundingClientRect().bottom+1&&this.toggle(!0)};get#d(){return r.get(this.el)!==this}constructor(e,t={}){if(typeof HTMLElement>`u`||!(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&&!(e[1]===``&&e[0].endsWith(`Class`))))};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.`);for(let e of[`expandedClass`,`openingClass`,`closingClass`]){let t=this.options[e];if(typeof t!=`string`||!/^\S+$/.test(t))throw RangeError(`ReadMore: ${e} must be a string without whitespace.`)}r.set(e,this),this.#h()}static init(t,n,i){if(typeof HTMLElement>`u`||typeof document>`u`)return[];let a=typeof t==`string`?(i??document).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.#m(o)}}get expanded(){return this.#o}toggle(e){let r=typeof e==`boolean`?e:!this.#o;if(this.#d||r===this.#o)return;r&&this.options.height!=null&&this.el.style.setProperty(`--readmore-full-height`,`${this.el.scrollHeight}px`),this.#o=r,this.el.classList.toggle(t,!this.#o),this.options.height!=null&&this.el.classList.toggle(n,!this.#o),this.el.classList.toggle(this.options.expandedClass,this.#o),this.#S();let i=this.#o?this.options.openingClass:this.options.closingClass,a=this.#o?this.options.closingClass:this.options.openingClass;this.el.classList.remove(a),this.#y(i),!this.#o&&this.options.scrollOnCollapse&&this.el.getBoundingClientRect().top<0&&this.el.scrollIntoView?.({block:`nearest`});try{this.options.onToggle?.(this.#o,this)}finally{this.el.dispatchEvent(new CustomEvent(`readmore:toggle`,{bubbles:!0,detail:{expanded:this.#o,instance:this}}))}}refresh(){this.#v(this.#_())}static getInstance(e){return r.get(e)}destroy(){this.#d||(r.delete(this.el),this.el.removeEventListener(`focusin`,this.#u),e.#e?.unobserve(this.el),e.#r.delete(this)&&e.#r.size===0&&(e.#i?.removeEventListener?.(`loadingdone`,e.#p),e.#i=void 0),this.#c!==null&&(clearTimeout(this.#c),this.#c=null),this.#C(),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.el.style.removeProperty(`--readmore-full-height`),this.#s&&=(this.el.removeAttribute(`id`),!1))}static#f(t){e.#m(t.map(e=>r.get(e.target)))}static#p=()=>{e.#m(Array.from(e.#r))};static#m(e){let t=/* @__PURE__ */ new Map;for(let n of e)n&&t.set(n,n.#_());t.forEach((e,t)=>t.#v(e))}#h(){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)),this.el.addEventListener(`focusin`,this.#u),e.#e??=new ResizeObserver(e.#f),e.#e.observe(this.el),e.#t?e.#t.push(this):this.refresh(),this.#g()}#g(){if(typeof document>`u`||!document.fonts)return;if(e.#i!==document.fonts&&(e.#i?.removeEventListener?.(`loadingdone`,e.#p),e.#i=document.fonts,e.#i.addEventListener?.(`loadingdone`,e.#p)),e.#r.add(this),e.#n){e.#n.push(this);return}let t=e.#n=[this];document.fonts.ready.then(()=>{e.#n=null,e.#m(t.filter(e=>!e.#d))})}#_(){return this.el.scrollHeight-this.el.clientHeight>1}#v(e){this.#o||this.#c!==null||this.#d||(e&&!this.#a?this.#x():!e&&this.#a&&this.#C())}#y(e){this.#c!==null&&clearTimeout(this.#c),this.el.classList.add(e);let t=setTimeout(()=>{let n=this.el.getAnimations?.().filter(e=>e instanceof CSSTransition)??[],r=()=>{this.#c===t&&(this.#c=null,this.el.classList.remove(e),this.refresh())};n.length?Promise.allSettled(n.map(e=>e.finished)).then(r):r()},this.#b());this.#c=t}#b(){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}#x(){if(!this.el.id){let e;do e=`readmore-${++i}`;while(document.getElementById(e));this.el.id=e,this.#s=!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.#l),this.#a=e,this.#S(),this.options.buttonContainer?this.options.buttonContainer.appendChild(e):this.el.insertAdjacentElement(`afterend`,e)}#S(){if(!this.#a)return;let{moreText:e,lessText:t,moreLabel:n,lessLabel:r}=this.options,i=this.#o?r:n;this.#a.textContent=this.#o?t:e,this.#a.setAttribute(`aria-expanded`,String(this.#o)),i?this.#a.setAttribute(`aria-label`,i):this.#a.removeAttribute(`aria-label`)}#C(){if(this.#a){if(this.#a.removeEventListener(`click`,this.#l),document.activeElement===this.#a){let{el:e}=this;e.hasAttribute(`tabindex`)||(e.tabIndex=-1,e.addEventListener(`blur`,()=>e.removeAttribute(`tabindex`),{once:!0})),e.focus()}this.#a.remove(),this.#a=null}}}})(this.FrontFactory=this.FrontFactory||{});
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,threshold:0,moreText:`Read more`,lessText:`Read less`,buttonClass:`readmore-btn`,expandedClass:`is-expanded`,openingClass:`is-opening`,closingClass:`is-closing`,scrollOnCollapse:!1};function o(e){return e.split(`,`).map(e=>parseFloat(e)*1e3||0)}e.ReadMore=class e{static#e;static#t=null;static#n=null;static#r=/* @__PURE__ */ new Set;static#i;el;options;#a=null;#o=!1;#s=!1;#c=null;#l=()=>this.toggle();#u=a.lines;#d;#f=e=>{let{target:t}=e;this.#o||!this.#a||t===this.el||!(t instanceof Element)||t.getBoundingClientRect().bottom+this.el.scrollTop>this.el.getBoundingClientRect().bottom+1&&this.toggle(!0)};get#p(){return r.get(this.el)!==this}constructor(e,t={}){if(typeof HTMLElement>`u`||!(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&&!(e[1]===``&&e[0].endsWith(`Class`))))};for(let e of[`height`,`lines`,`threshold`]){let t=this.options[e];typeof t==`string`&&t.trim()!==``&&(this.options[e]=Number(t))}let{threshold:n,buttonContainer:i}=this.options;if(i!=null&&!(i instanceof HTMLElement))throw TypeError(`ReadMore: buttonContainer must be an HTMLElement.`);if(this.#m(),!(Number.isFinite(n)&&n>=0))throw RangeError(`ReadMore: threshold must be a finite number of at least 0.`);for(let e of[`expandedClass`,`openingClass`,`closingClass`]){let t=this.options[e];if(typeof t!=`string`||!/^\S+$/.test(t))throw RangeError(`ReadMore: ${e} must be a string without whitespace.`)}r.set(e,this),this.#y()}static init(t,n,i){if(typeof HTMLElement>`u`||typeof document>`u`)return[];let a=typeof t==`string`?(i??document).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.#v(o)}}get expanded(){return this.#o}toggle(e){let r=typeof e==`boolean`?e:!this.#o;if(this.#p||r===this.#o||this.options.onBeforeToggle?.(r,this)===!1)return;let i=new CustomEvent(`readmore:beforetoggle`,{bubbles:!0,cancelable:!0,detail:{expanded:r,instance:this}});if(!this.el.dispatchEvent(i))return;r&&this.options.height!=null&&this.el.style.setProperty(`--readmore-full-height`,`${this.el.scrollHeight}px`),this.#o=r,this.el.classList.toggle(t,!this.#o),this.options.height!=null&&this.el.classList.toggle(n,!this.#o),this.el.classList.toggle(this.options.expandedClass,this.#o),this.#E();let a=this.#o?this.options.openingClass:this.options.closingClass,o=this.#o?this.options.closingClass:this.options.openingClass;this.el.classList.remove(o),this.#C(a),!this.#o&&this.options.scrollOnCollapse&&this.el.getBoundingClientRect().top<0&&this.el.scrollIntoView?.({block:`nearest`});try{this.options.onToggle?.(this.#o,this)}finally{this.el.dispatchEvent(new CustomEvent(`readmore:toggle`,{bubbles:!0,detail:{expanded:this.#o,instance:this}}))}}refresh(){this.#p||(this.#m(),this.#h()),this.#S(this.#x())}#m(){let e=e=>{let t=typeof e==`function`?e(this.el):e;return typeof t==`string`&&t.trim()!==``?Number(t):t},t=e(this.options.height),n=e(this.options.lines);if(t!=null&&!(Number.isFinite(t)&&t>0))throw RangeError(`ReadMore: height must be a finite number greater than 0.`);if(!(Number.isInteger(n)&&n>=1))throw RangeError(`ReadMore: lines must be an integer of at least 1.`);this.#d=t,this.#u=n}#h(){this.options.height==null?this.el.style.setProperty(`--readmore-lines`,String(this.#u)):this.el.style.setProperty(`--readmore-height`,`${this.#d}px`)}static getInstance(e){return r.get(e)}destroy(){this.#p||(r.delete(this.el),this.el.removeEventListener(`focusin`,this.#f),e.#e?.unobserve(this.el),e.#r.delete(this)&&e.#r.size===0&&(e.#i?.removeEventListener?.(`loadingdone`,e.#_),e.#i=void 0),this.#c!==null&&(clearTimeout(this.#c),this.#c=null),this.#D(),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.el.style.removeProperty(`--readmore-full-height`),this.#s&&=(this.el.removeAttribute(`id`),!1))}static#g(t){e.#v(t.map(e=>r.get(e.target)))}static#_=()=>{e.#v(Array.from(e.#r))};static#v(e){let t=/* @__PURE__ */ new Map;for(let n of e)n&&t.set(n,n.#x());t.forEach((e,t)=>t.#S(e))}#y(){this.#h(),this.el.classList.add(t),this.options.height!=null&&this.el.classList.add(n),this.el.addEventListener(`focusin`,this.#f),e.#e??=new ResizeObserver(e.#g),e.#e.observe(this.el),e.#t?e.#t.push(this):this.refresh(),this.#b()}#b(){if(typeof document>`u`||!document.fonts)return;if(e.#i!==document.fonts&&(e.#i?.removeEventListener?.(`loadingdone`,e.#_),e.#i=document.fonts,e.#i.addEventListener?.(`loadingdone`,e.#_)),e.#r.add(this),e.#n){e.#n.push(this);return}let t=e.#n=[this];document.fonts.ready.then(()=>{e.#n=null,e.#v(t.filter(e=>!e.#p))})}#x(){return this.el.scrollHeight-this.el.clientHeight>Math.max(1,this.options.threshold)}#S(e){this.#o||this.#c!==null||this.#p||(e&&!this.#a?this.#T():!e&&this.#a&&this.#D())}#C(e){this.#c!==null&&clearTimeout(this.#c),this.el.classList.add(e);let t=setTimeout(()=>{let n=this.el.getAnimations?.().filter(e=>e instanceof CSSTransition)??[],r=()=>{this.#c===t&&(this.#c=null,this.el.classList.remove(e),this.refresh())};n.length?Promise.allSettled(n.map(e=>e.finished)).then(r):r()},this.#w());this.#c=t}#w(){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}#T(){if(!this.el.id){let e;do e=`readmore-${++i}`;while(document.getElementById(e));this.el.id=e,this.#s=!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.#l),this.#a=e,this.#E(),this.options.buttonContainer?this.options.buttonContainer.appendChild(e):this.el.insertAdjacentElement(`afterend`,e)}#E(){if(!this.#a)return;let{moreText:e,lessText:t,moreLabel:n,lessLabel:r}=this.options,i=this.#o?r:n;this.#a.textContent=this.#o?t:e,this.#a.setAttribute(`aria-expanded`,String(this.#o)),i?this.#a.setAttribute(`aria-label`,i):this.#a.removeAttribute(`aria-label`)}#D(){if(this.#a){if(this.#a.removeEventListener(`click`,this.#l),document.activeElement===this.#a){let{el:e}=this;e.hasAttribute(`tabindex`)||(e.tabIndex=-1,e.addEventListener(`blur`,()=>e.removeAttribute(`tabindex`),{once:!0})),e.focus()}this.#a.remove(),this.#a=null}}}})(this.FrontFactory=this.FrontFactory||{});
2
2
  //# sourceMappingURL=readmore.iife.js.map
@@ -1 +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","#live","#fonts","#expanded","#button","target","#destroyed","element","options","entry","key","value","buttonContainer","height","lines","#init","nodes","root","batch","n","#syncAll","expanded","force","#updateButton","stateClass","otherClass","#applyTransientClass","#sync","#isOverflowing","#onFocusIn","#onFontsLoaded","#transientTimer","#unmountButton","#generatedId","#onResize","entries","measured","instance","overflowing","#refreshOnFonts","queue","#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 * Scrolls the element back into view when collapsing, if its top edge ended\n * up above the viewport.\n *\n * @defaultValue false\n */\n scrollOnCollapse?: boolean;\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 scrollOnCollapse: false\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. Focus landing on an element in the hidden part expands\n * the text.\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 /** Live instances resynced by the shared `document.fonts` `loadingdone` listener. */\n static #live = new Set<ReadMore>();\n\n /** The `document.fonts` the shared listener is attached to. */\n static #fonts: FontFaceSet | undefined;\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 #onFocusIn = (e: FocusEvent): void => {\n const { target } = e;\n\n if (this.#expanded || !this.#button || target === this.el || !(target instanceof Element)) {\n return;\n }\n\n // The browser may already have scrolled the hidden overflow to reveal the target: add it back.\n if (target.getBoundingClientRect().bottom + this.el.scrollTop > this.el.getBoundingClientRect().bottom + 1) {\n this.toggle(true);\n }\n };\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`, or there is no\n * `HTMLElement` at all (no browser, e.g. SSR).\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 RangeError - if `expandedClass`, `openingClass` or `closingClass`\n * is not a string or contains whitespace (an empty string means the default).\n * @throws TypeError - if `buttonContainer` is set but is not an `HTMLElement`.\n */\n constructor(element: HTMLElement, options: ReadMoreOptions = {}) {\n if (typeof HTMLElement === 'undefined' || !(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` (or an empty state class) must not override the defaults.\n this.options = {\n ...DEFAULTS,\n ...Object.fromEntries(Object.entries(options).filter((entry) => entry[1] !== undefined\n && !(entry[1] === '' && entry[0].endsWith('Class'))))\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 for (const key of [\n 'expandedClass',\n 'openingClass',\n 'closingClass'\n ] as const) {\n const value: unknown = this.options[key];\n\n if (typeof value !== 'string' || !/^\\S+$/.test(value)) {\n throw new RangeError(`ReadMore: ${key} must be a string without whitespace.`);\n }\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. Defaults to `document`.\n * @returns One instance per element, in document order; `[]` without a\n * browser (e.g. SSR).\n */\n static init(\n target: string | HTMLElement | Iterable<HTMLElement> | ArrayLike<HTMLElement>,\n options?: ReadMoreOptions,\n root?: ParentNode\n ): ReadMore[] {\n if (typeof HTMLElement === 'undefined' || typeof document === 'undefined') {\n return [];\n }\n\n const nodes =\n typeof target === 'string'\n ? (root ?? document).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 // Measured while still clamped: scrollHeight is then the full content height.\n if (expanded && this.options.height != null) {\n this.el.style.setProperty('--readmore-full-height', `${ this.el.scrollHeight }px`);\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 if (!this.#expanded && this.options.scrollOnCollapse && this.el.getBoundingClientRect().top < 0) {\n this.el.scrollIntoView?.({\n block: 'nearest'\n });\n }\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 this.el.removeEventListener('focusin', this.#onFocusIn);\n ReadMore.#resizeObserver?.unobserve(this.el);\n\n if (ReadMore.#live.delete(this) && ReadMore.#live.size === 0) {\n ReadMore.#fonts?.removeEventListener?.('loadingdone', ReadMore.#onFontsLoaded);\n ReadMore.#fonts = undefined;\n }\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 this.el.style.removeProperty('--readmore-full-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 #onFontsLoaded = (): void => {\n ReadMore.#syncAll(Array.from(ReadMore.#live));\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 this.el.addEventListener('focusin', this.#onFocusIn);\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 // Fonts loaded later (after `ready`) are caught by one shared listener.\n if (ReadMore.#fonts !== document.fonts) {\n ReadMore.#fonts?.removeEventListener?.('loadingdone', ReadMore.#onFontsLoaded);\n ReadMore.#fonts = document.fonts;\n ReadMore.#fonts.addEventListener?.('loadingdone', ReadMore.#onFontsLoaded);\n }\n\n ReadMore.#live.add(this);\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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"}
1
+ {"version":3,"file":"readmore.iife.js","names":["exports","CLAMP_CLASS","CLAMP_HEIGHT_CLASS","instances","idCounter","DEFAULTS","toMs","list","v","ReadMore","#resizeObserver","#batch","#fontQueue","#live","#fonts","#expanded","#button","target","#destroyed","element","options","entry","key","value","buttonContainer","#resolveSizes","threshold","#init","nodes","root","batch","n","#syncAll","expanded","force","before","#updateButton","stateClass","otherClass","#applyTransientClass","#applySizes","#sync","#isOverflowing","read","result","height","lines","#height","#lines","#onFocusIn","#onFontsLoaded","#transientTimer","#unmountButton","#generatedId","#onResize","entries","measured","instance","overflowing","#refreshOnFonts","queue","#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 * Can also be a function called with the element; the returned value is\n * validated like a literal one and re-evaluated on {@link ReadMore.refresh}.\n *\n * @throws RangeError - if not an integer greater than or equal to 1.\n */\n lines?: number | ((el: HTMLElement) => 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 * Can also be a function called with the element; the returned value is\n * validated like a literal one and re-evaluated on {@link ReadMore.refresh}.\n *\n * @throws RangeError - if not a finite number greater than 0.\n */\n height?: number | ((el: HTMLElement) => number);\n\n /**\n * Hidden overflow in pixels tolerated before the button is mounted: the\n * button only appears when more than this is hidden (at least 1px).\n *\n * @defaultValue 0\n * @throws RangeError - if not a finite number greater than or equal to 0.\n */\n threshold?: 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 * Scrolls the element back into view when collapsing, if its top edge ended\n * up above the viewport.\n *\n * @defaultValue false\n */\n scrollOnCollapse?: boolean;\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 /**\n * Called before a toggle that would really happen, with the state it is\n * about to enter. Returning exactly `false` cancels it (the\n * `readmore:beforetoggle` event is then not dispatched). If it throws, the\n * error propagates and the toggle does not happen.\n *\n * @param expanded - `true` if about to expand, `false` if about to collapse.\n * @param instance - The instance about to be toggled.\n */\n onBeforeToggle?: (expanded: boolean, instance: ReadMore) => boolean | void;\n}\n\ntype OptionalKeys = 'height' | 'buttonContainer' | 'moreLabel' | 'lessLabel' | 'onToggle' | 'onBeforeToggle';\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). `readmore:beforetoggle` is cancelable\n// and carries the state about to be entered.\ndeclare global {\n interface HTMLElementEventMap {\n 'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;\n 'readmore:beforetoggle': CustomEvent<ReadMoreToggleDetail>;\n }\n\n interface DocumentEventMap {\n 'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;\n 'readmore:beforetoggle': 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 threshold: 0,\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 scrollOnCollapse: false\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. Focus landing on an element in the hidden part expands\n * the text.\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 /** Live instances resynced by the shared `document.fonts` `loadingdone` listener. */\n static #live = new Set<ReadMore>();\n\n /** The `document.fonts` the shared listener is attached to. */\n static #fonts: FontFaceSet | undefined;\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 #lines = DEFAULTS.lines as number;\n #height: number | undefined;\n\n #onFocusIn = (e: FocusEvent): void => {\n const { target } = e;\n\n if (this.#expanded || !this.#button || target === this.el || !(target instanceof Element)) {\n return;\n }\n\n // The browser may already have scrolled the hidden overflow to reveal the target: add it back.\n if (target.getBoundingClientRect().bottom + this.el.scrollTop > this.el.getBoundingClientRect().bottom + 1) {\n this.toggle(true);\n }\n };\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`, or there is no\n * `HTMLElement` at all (no browser, e.g. SSR).\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, `height` is not a finite number greater than 0, or `threshold` is\n * not a finite number greater than or equal to 0 (for `lines` and `height`,\n * the value returned by a function is checked).\n * @throws RangeError - if `expandedClass`, `openingClass` or `closingClass`\n * is not a string or contains whitespace (an empty string means the default).\n * @throws TypeError - if `buttonContainer` is set but is not an `HTMLElement`.\n */\n constructor(element: HTMLElement, options: ReadMoreOptions = {}) {\n if (typeof HTMLElement === 'undefined' || !(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` (or an empty state class) must not override the defaults.\n this.options = {\n ...DEFAULTS,\n ...Object.fromEntries(Object.entries(options).filter((entry) => entry[1] !== undefined\n && !(entry[1] === '' && entry[0].endsWith('Class'))))\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 'threshold'\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 { threshold, buttonContainer } = this.options;\n\n if (buttonContainer != null && !(buttonContainer instanceof HTMLElement)) {\n throw new TypeError('ReadMore: buttonContainer must be an HTMLElement.');\n }\n\n this.#resolveSizes();\n\n if (!(Number.isFinite(threshold) && threshold >= 0)) {\n throw new RangeError('ReadMore: threshold must be a finite number of at least 0.');\n }\n\n for (const key of [\n 'expandedClass',\n 'openingClass',\n 'closingClass'\n ] as const) {\n const value: unknown = this.options[key];\n\n if (typeof value !== 'string' || !/^\\S+$/.test(value)) {\n throw new RangeError(`ReadMore: ${key} must be a string without whitespace.`);\n }\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. Defaults to `document`.\n * @returns One instance per element, in document order; `[]` without a\n * browser (e.g. SSR).\n */\n static init(\n target: string | HTMLElement | Iterable<HTMLElement> | ArrayLike<HTMLElement>,\n options?: ReadMoreOptions,\n root?: ParentNode\n ): ReadMore[] {\n if (typeof HTMLElement === 'undefined' || typeof document === 'undefined') {\n return [];\n }\n\n const nodes =\n typeof target === 'string'\n ? (root ?? document).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. Before a real toggle, {@link ReadMoreOptions.onBeforeToggle}\n * and a cancelable `readmore:beforetoggle` event can cancel it.\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 if (this.options.onBeforeToggle?.(expanded, this) === false) {\n return;\n }\n\n const before = new CustomEvent<ReadMoreToggleDetail>('readmore:beforetoggle', {\n bubbles: true,\n cancelable: true,\n detail: {\n expanded,\n instance: this\n }\n });\n\n if (!this.el.dispatchEvent(before)) {\n return;\n }\n\n // Measured while still clamped: scrollHeight is then the full content height.\n if (expanded && this.options.height != null) {\n this.el.style.setProperty('--readmore-full-height', `${ this.el.scrollHeight }px`);\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 if (!this.#expanded && this.options.scrollOnCollapse && this.el.getBoundingClientRect().top < 0) {\n this.el.scrollIntoView?.({\n block: 'nearest'\n });\n }\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 if (!this.#destroyed) {\n this.#resolveSizes();\n this.#applySizes();\n }\n\n this.#sync(this.#isOverflowing());\n }\n\n // Evaluates function values of `lines` / `height` (on the element), then validates like literals.\n #resolveSizes(): void {\n const read = (value: unknown): unknown => {\n const result = typeof value === 'function' ? value(this.el) : value;\n\n return typeof result === 'string' && result.trim() !== '' ? Number(result) : result;\n };\n\n const height = read(this.options.height) as number | undefined;\n const lines = read(this.options.lines) as number;\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 this.#height = height;\n this.#lines = lines;\n }\n\n #applySizes(): void {\n if (this.options.height != null) {\n this.el.style.setProperty('--readmore-height', `${ this.#height }px`);\n } else {\n this.el.style.setProperty('--readmore-lines', String(this.#lines));\n }\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 this.el.removeEventListener('focusin', this.#onFocusIn);\n ReadMore.#resizeObserver?.unobserve(this.el);\n\n if (ReadMore.#live.delete(this) && ReadMore.#live.size === 0) {\n ReadMore.#fonts?.removeEventListener?.('loadingdone', ReadMore.#onFontsLoaded);\n ReadMore.#fonts = undefined;\n }\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 this.el.style.removeProperty('--readmore-full-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 #onFontsLoaded = (): void => {\n ReadMore.#syncAll(Array.from(ReadMore.#live));\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 this.#applySizes();\n this.el.classList.add(CLAMP_CLASS);\n\n if (this.options.height != null) {\n this.el.classList.add(CLAMP_HEIGHT_CLASS);\n }\n\n this.el.addEventListener('focusin', this.#onFocusIn);\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 // Fonts loaded later (after `ready`) are caught by one shared listener.\n if (ReadMore.#fonts !== document.fonts) {\n ReadMore.#fonts?.removeEventListener?.('loadingdone', ReadMore.#onFontsLoaded);\n ReadMore.#fonts = document.fonts;\n ReadMore.#fonts.addEventListener?.('loadingdone', ReadMore.#onFontsLoaded);\n }\n\n ReadMore.#live.add(this);\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 > Math.max(1, this.options.threshold);\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
@@ -5,6 +5,7 @@ var instances = /* @__PURE__ */ new WeakMap();
5
5
  var idCounter = 0;
6
6
  var DEFAULTS = {
7
7
  lines: 3,
8
+ threshold: 0,
8
9
  moreText: "Read more",
9
10
  lessText: "Read less",
10
11
  buttonClass: "readmore-btn",
@@ -54,6 +55,8 @@ var ReadMore = class ReadMore {
54
55
  #generatedId = false;
55
56
  #transientTimer = null;
56
57
  #onClick = () => this.toggle();
58
+ #lines = DEFAULTS.lines;
59
+ #height;
57
60
  #onFocusIn = (e) => {
58
61
  const { target } = e;
59
62
  if (this.#expanded || !this.#button || target === this.el || !(target instanceof Element)) return;
@@ -70,7 +73,9 @@ var ReadMore = class ReadMore {
70
73
  * @throws Error - if `element` is already initialized; call
71
74
  * {@link ReadMore.destroy} first.
72
75
  * @throws RangeError - if `lines` is not an integer greater than or equal
73
- * to 1, or `height` is not a finite number greater than 0.
76
+ * to 1, `height` is not a finite number greater than 0, or `threshold` is
77
+ * not a finite number greater than or equal to 0 (for `lines` and `height`,
78
+ * the value returned by a function is checked).
74
79
  * @throws RangeError - if `expandedClass`, `openingClass` or `closingClass`
75
80
  * is not a string or contains whitespace (an empty string means the default).
76
81
  * @throws TypeError - if `buttonContainer` is set but is not an `HTMLElement`.
@@ -83,14 +88,18 @@ var ReadMore = class ReadMore {
83
88
  ...DEFAULTS,
84
89
  ...Object.fromEntries(Object.entries(options).filter((entry) => entry[1] !== void 0 && !(entry[1] === "" && entry[0].endsWith("Class"))))
85
90
  };
86
- for (const key of ["height", "lines"]) {
91
+ for (const key of [
92
+ "height",
93
+ "lines",
94
+ "threshold"
95
+ ]) {
87
96
  const value = this.options[key];
88
97
  if (typeof value === "string" && value.trim() !== "") this.options[key] = Number(value);
89
98
  }
90
- const { height, lines, buttonContainer } = this.options;
99
+ const { threshold, buttonContainer } = this.options;
91
100
  if (buttonContainer != null && !(buttonContainer instanceof HTMLElement)) throw new TypeError("ReadMore: buttonContainer must be an HTMLElement.");
92
- if (height != null && !(Number.isFinite(height) && height > 0)) throw new RangeError("ReadMore: height must be a finite number greater than 0.");
93
- if (!(Number.isInteger(lines) && lines >= 1)) throw new RangeError("ReadMore: lines must be an integer of at least 1.");
101
+ this.#resolveSizes();
102
+ if (!(Number.isFinite(threshold) && threshold >= 0)) throw new RangeError("ReadMore: threshold must be a finite number of at least 0.");
94
103
  for (const key of [
95
104
  "expandedClass",
96
105
  "openingClass",
@@ -139,11 +148,22 @@ var ReadMore = class ReadMore {
139
148
  *
140
149
  * @param force - `true` to expand, `false` to collapse; the state is
141
150
  * inverted when omitted. Does nothing if the text is already in the
142
- * requested state.
151
+ * requested state. Before a real toggle, {@link ReadMoreOptions.onBeforeToggle}
152
+ * and a cancelable `readmore:beforetoggle` event can cancel it.
143
153
  */
144
154
  toggle(force) {
145
155
  const expanded = typeof force === "boolean" ? force : !this.#expanded;
146
156
  if (this.#destroyed || expanded === this.#expanded) return;
157
+ if (this.options.onBeforeToggle?.(expanded, this) === false) return;
158
+ const before = new CustomEvent("readmore:beforetoggle", {
159
+ bubbles: true,
160
+ cancelable: true,
161
+ detail: {
162
+ expanded,
163
+ instance: this
164
+ }
165
+ });
166
+ if (!this.el.dispatchEvent(before)) return;
147
167
  if (expanded && this.options.height != null) this.el.style.setProperty("--readmore-full-height", `${this.el.scrollHeight}px`);
148
168
  this.#expanded = expanded;
149
169
  this.el.classList.toggle(CLAMP_CLASS, !this.#expanded);
@@ -174,8 +194,28 @@ var ReadMore = class ReadMore {
174
194
  * while expanded, during a toggle transition, or once destroyed.
175
195
  */
176
196
  refresh() {
197
+ if (!this.#destroyed) {
198
+ this.#resolveSizes();
199
+ this.#applySizes();
200
+ }
177
201
  this.#sync(this.#isOverflowing());
178
202
  }
203
+ #resolveSizes() {
204
+ const read = (value) => {
205
+ const result = typeof value === "function" ? value(this.el) : value;
206
+ return typeof result === "string" && result.trim() !== "" ? Number(result) : result;
207
+ };
208
+ const height = read(this.options.height);
209
+ const lines = read(this.options.lines);
210
+ if (height != null && !(Number.isFinite(height) && height > 0)) throw new RangeError("ReadMore: height must be a finite number greater than 0.");
211
+ if (!(Number.isInteger(lines) && lines >= 1)) throw new RangeError("ReadMore: lines must be an integer of at least 1.");
212
+ this.#height = height;
213
+ this.#lines = lines;
214
+ }
215
+ #applySizes() {
216
+ if (this.options.height != null) this.el.style.setProperty("--readmore-height", `${this.#height}px`);
217
+ else this.el.style.setProperty("--readmore-lines", String(this.#lines));
218
+ }
179
219
  /**
180
220
  * @param element - An element possibly initialized by this plugin.
181
221
  * @returns The instance bound to it, or `undefined`.
@@ -223,13 +263,9 @@ var ReadMore = class ReadMore {
223
263
  measured.forEach((overflowing, instance) => instance.#sync(overflowing));
224
264
  }
225
265
  #init() {
226
- if (this.options.height != null) {
227
- this.el.style.setProperty("--readmore-height", `${this.options.height}px`);
228
- this.el.classList.add(CLAMP_CLASS, CLAMP_HEIGHT_CLASS);
229
- } else {
230
- this.el.style.setProperty("--readmore-lines", String(this.options.lines));
231
- this.el.classList.add(CLAMP_CLASS);
232
- }
266
+ this.#applySizes();
267
+ this.el.classList.add(CLAMP_CLASS);
268
+ if (this.options.height != null) this.el.classList.add(CLAMP_HEIGHT_CLASS);
233
269
  this.el.addEventListener("focusin", this.#onFocusIn);
234
270
  ReadMore.#resizeObserver ??= new ResizeObserver(ReadMore.#onResize);
235
271
  ReadMore.#resizeObserver.observe(this.el);
@@ -256,7 +292,7 @@ var ReadMore = class ReadMore {
256
292
  });
257
293
  }
258
294
  #isOverflowing() {
259
- return this.el.scrollHeight - this.el.clientHeight > 1;
295
+ return this.el.scrollHeight - this.el.clientHeight > Math.max(1, this.options.threshold);
260
296
  }
261
297
  #sync(overflowing) {
262
298
  if (this.#expanded || this.#transientTimer !== null || this.#destroyed) return;
@@ -1 +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 * Scrolls the element back into view when collapsing, if its top edge ended\n * up above the viewport.\n *\n * @defaultValue false\n */\n scrollOnCollapse?: boolean;\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 scrollOnCollapse: false\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. Focus landing on an element in the hidden part expands\n * the text.\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 /** Live instances resynced by the shared `document.fonts` `loadingdone` listener. */\n static #live = new Set<ReadMore>();\n\n /** The `document.fonts` the shared listener is attached to. */\n static #fonts: FontFaceSet | undefined;\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 #onFocusIn = (e: FocusEvent): void => {\n const { target } = e;\n\n if (this.#expanded || !this.#button || target === this.el || !(target instanceof Element)) {\n return;\n }\n\n // The browser may already have scrolled the hidden overflow to reveal the target: add it back.\n if (target.getBoundingClientRect().bottom + this.el.scrollTop > this.el.getBoundingClientRect().bottom + 1) {\n this.toggle(true);\n }\n };\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`, or there is no\n * `HTMLElement` at all (no browser, e.g. SSR).\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 RangeError - if `expandedClass`, `openingClass` or `closingClass`\n * is not a string or contains whitespace (an empty string means the default).\n * @throws TypeError - if `buttonContainer` is set but is not an `HTMLElement`.\n */\n constructor(element: HTMLElement, options: ReadMoreOptions = {}) {\n if (typeof HTMLElement === 'undefined' || !(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` (or an empty state class) must not override the defaults.\n this.options = {\n ...DEFAULTS,\n ...Object.fromEntries(Object.entries(options).filter((entry) => entry[1] !== undefined\n && !(entry[1] === '' && entry[0].endsWith('Class'))))\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 for (const key of [\n 'expandedClass',\n 'openingClass',\n 'closingClass'\n ] as const) {\n const value: unknown = this.options[key];\n\n if (typeof value !== 'string' || !/^\\S+$/.test(value)) {\n throw new RangeError(`ReadMore: ${key} must be a string without whitespace.`);\n }\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. Defaults to `document`.\n * @returns One instance per element, in document order; `[]` without a\n * browser (e.g. SSR).\n */\n static init(\n target: string | HTMLElement | Iterable<HTMLElement> | ArrayLike<HTMLElement>,\n options?: ReadMoreOptions,\n root?: ParentNode\n ): ReadMore[] {\n if (typeof HTMLElement === 'undefined' || typeof document === 'undefined') {\n return [];\n }\n\n const nodes =\n typeof target === 'string'\n ? (root ?? document).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 // Measured while still clamped: scrollHeight is then the full content height.\n if (expanded && this.options.height != null) {\n this.el.style.setProperty('--readmore-full-height', `${ this.el.scrollHeight }px`);\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 if (!this.#expanded && this.options.scrollOnCollapse && this.el.getBoundingClientRect().top < 0) {\n this.el.scrollIntoView?.({\n block: 'nearest'\n });\n }\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 this.el.removeEventListener('focusin', this.#onFocusIn);\n ReadMore.#resizeObserver?.unobserve(this.el);\n\n if (ReadMore.#live.delete(this) && ReadMore.#live.size === 0) {\n ReadMore.#fonts?.removeEventListener?.('loadingdone', ReadMore.#onFontsLoaded);\n ReadMore.#fonts = undefined;\n }\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 this.el.style.removeProperty('--readmore-full-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 #onFontsLoaded = (): void => {\n ReadMore.#syncAll(Array.from(ReadMore.#live));\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 this.el.addEventListener('focusin', this.#onFocusIn);\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 // Fonts loaded later (after `ready`) are caught by one shared listener.\n if (ReadMore.#fonts !== document.fonts) {\n ReadMore.#fonts?.removeEventListener?.('loadingdone', ReadMore.#onFontsLoaded);\n ReadMore.#fonts = document.fonts;\n ReadMore.#fonts.addEventListener?.('loadingdone', ReadMore.#onFontsLoaded);\n }\n\n ReadMore.#live.add(this);\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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"}
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 * Can also be a function called with the element; the returned value is\n * validated like a literal one and re-evaluated on {@link ReadMore.refresh}.\n *\n * @throws RangeError - if not an integer greater than or equal to 1.\n */\n lines?: number | ((el: HTMLElement) => 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 * Can also be a function called with the element; the returned value is\n * validated like a literal one and re-evaluated on {@link ReadMore.refresh}.\n *\n * @throws RangeError - if not a finite number greater than 0.\n */\n height?: number | ((el: HTMLElement) => number);\n\n /**\n * Hidden overflow in pixels tolerated before the button is mounted: the\n * button only appears when more than this is hidden (at least 1px).\n *\n * @defaultValue 0\n * @throws RangeError - if not a finite number greater than or equal to 0.\n */\n threshold?: 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 * Scrolls the element back into view when collapsing, if its top edge ended\n * up above the viewport.\n *\n * @defaultValue false\n */\n scrollOnCollapse?: boolean;\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 /**\n * Called before a toggle that would really happen, with the state it is\n * about to enter. Returning exactly `false` cancels it (the\n * `readmore:beforetoggle` event is then not dispatched). If it throws, the\n * error propagates and the toggle does not happen.\n *\n * @param expanded - `true` if about to expand, `false` if about to collapse.\n * @param instance - The instance about to be toggled.\n */\n onBeforeToggle?: (expanded: boolean, instance: ReadMore) => boolean | void;\n}\n\ntype OptionalKeys = 'height' | 'buttonContainer' | 'moreLabel' | 'lessLabel' | 'onToggle' | 'onBeforeToggle';\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). `readmore:beforetoggle` is cancelable\n// and carries the state about to be entered.\ndeclare global {\n interface HTMLElementEventMap {\n 'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;\n 'readmore:beforetoggle': CustomEvent<ReadMoreToggleDetail>;\n }\n\n interface DocumentEventMap {\n 'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;\n 'readmore:beforetoggle': 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 threshold: 0,\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 scrollOnCollapse: false\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. Focus landing on an element in the hidden part expands\n * the text.\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 /** Live instances resynced by the shared `document.fonts` `loadingdone` listener. */\n static #live = new Set<ReadMore>();\n\n /** The `document.fonts` the shared listener is attached to. */\n static #fonts: FontFaceSet | undefined;\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 #lines = DEFAULTS.lines as number;\n #height: number | undefined;\n\n #onFocusIn = (e: FocusEvent): void => {\n const { target } = e;\n\n if (this.#expanded || !this.#button || target === this.el || !(target instanceof Element)) {\n return;\n }\n\n // The browser may already have scrolled the hidden overflow to reveal the target: add it back.\n if (target.getBoundingClientRect().bottom + this.el.scrollTop > this.el.getBoundingClientRect().bottom + 1) {\n this.toggle(true);\n }\n };\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`, or there is no\n * `HTMLElement` at all (no browser, e.g. SSR).\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, `height` is not a finite number greater than 0, or `threshold` is\n * not a finite number greater than or equal to 0 (for `lines` and `height`,\n * the value returned by a function is checked).\n * @throws RangeError - if `expandedClass`, `openingClass` or `closingClass`\n * is not a string or contains whitespace (an empty string means the default).\n * @throws TypeError - if `buttonContainer` is set but is not an `HTMLElement`.\n */\n constructor(element: HTMLElement, options: ReadMoreOptions = {}) {\n if (typeof HTMLElement === 'undefined' || !(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` (or an empty state class) must not override the defaults.\n this.options = {\n ...DEFAULTS,\n ...Object.fromEntries(Object.entries(options).filter((entry) => entry[1] !== undefined\n && !(entry[1] === '' && entry[0].endsWith('Class'))))\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 'threshold'\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 { threshold, buttonContainer } = this.options;\n\n if (buttonContainer != null && !(buttonContainer instanceof HTMLElement)) {\n throw new TypeError('ReadMore: buttonContainer must be an HTMLElement.');\n }\n\n this.#resolveSizes();\n\n if (!(Number.isFinite(threshold) && threshold >= 0)) {\n throw new RangeError('ReadMore: threshold must be a finite number of at least 0.');\n }\n\n for (const key of [\n 'expandedClass',\n 'openingClass',\n 'closingClass'\n ] as const) {\n const value: unknown = this.options[key];\n\n if (typeof value !== 'string' || !/^\\S+$/.test(value)) {\n throw new RangeError(`ReadMore: ${key} must be a string without whitespace.`);\n }\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. Defaults to `document`.\n * @returns One instance per element, in document order; `[]` without a\n * browser (e.g. SSR).\n */\n static init(\n target: string | HTMLElement | Iterable<HTMLElement> | ArrayLike<HTMLElement>,\n options?: ReadMoreOptions,\n root?: ParentNode\n ): ReadMore[] {\n if (typeof HTMLElement === 'undefined' || typeof document === 'undefined') {\n return [];\n }\n\n const nodes =\n typeof target === 'string'\n ? (root ?? document).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. Before a real toggle, {@link ReadMoreOptions.onBeforeToggle}\n * and a cancelable `readmore:beforetoggle` event can cancel it.\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 if (this.options.onBeforeToggle?.(expanded, this) === false) {\n return;\n }\n\n const before = new CustomEvent<ReadMoreToggleDetail>('readmore:beforetoggle', {\n bubbles: true,\n cancelable: true,\n detail: {\n expanded,\n instance: this\n }\n });\n\n if (!this.el.dispatchEvent(before)) {\n return;\n }\n\n // Measured while still clamped: scrollHeight is then the full content height.\n if (expanded && this.options.height != null) {\n this.el.style.setProperty('--readmore-full-height', `${ this.el.scrollHeight }px`);\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 if (!this.#expanded && this.options.scrollOnCollapse && this.el.getBoundingClientRect().top < 0) {\n this.el.scrollIntoView?.({\n block: 'nearest'\n });\n }\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 if (!this.#destroyed) {\n this.#resolveSizes();\n this.#applySizes();\n }\n\n this.#sync(this.#isOverflowing());\n }\n\n // Evaluates function values of `lines` / `height` (on the element), then validates like literals.\n #resolveSizes(): void {\n const read = (value: unknown): unknown => {\n const result = typeof value === 'function' ? value(this.el) : value;\n\n return typeof result === 'string' && result.trim() !== '' ? Number(result) : result;\n };\n\n const height = read(this.options.height) as number | undefined;\n const lines = read(this.options.lines) as number;\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 this.#height = height;\n this.#lines = lines;\n }\n\n #applySizes(): void {\n if (this.options.height != null) {\n this.el.style.setProperty('--readmore-height', `${ this.#height }px`);\n } else {\n this.el.style.setProperty('--readmore-lines', String(this.#lines));\n }\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 this.el.removeEventListener('focusin', this.#onFocusIn);\n ReadMore.#resizeObserver?.unobserve(this.el);\n\n if (ReadMore.#live.delete(this) && ReadMore.#live.size === 0) {\n ReadMore.#fonts?.removeEventListener?.('loadingdone', ReadMore.#onFontsLoaded);\n ReadMore.#fonts = undefined;\n }\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 this.el.style.removeProperty('--readmore-full-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 #onFontsLoaded = (): void => {\n ReadMore.#syncAll(Array.from(ReadMore.#live));\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 this.#applySizes();\n this.el.classList.add(CLAMP_CLASS);\n\n if (this.options.height != null) {\n this.el.classList.add(CLAMP_HEIGHT_CLASS);\n }\n\n this.el.addEventListener('focusin', this.#onFocusIn);\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 // Fonts loaded later (after `ready`) are caught by one shared listener.\n if (ReadMore.#fonts !== document.fonts) {\n ReadMore.#fonts?.removeEventListener?.('loadingdone', ReadMore.#onFontsLoaded);\n ReadMore.#fonts = document.fonts;\n ReadMore.#fonts.addEventListener?.('loadingdone', ReadMore.#onFontsLoaded);\n }\n\n ReadMore.#live.add(this);\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 > Math.max(1, this.options.threshold);\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,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontfactory/readmore",
3
- "version": "1.6.0",
3
+ "version": "1.7.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": {