@frontfactory/readmore 1.5.0 → 1.6.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 +21 -8
- package/dist/readmore.d.ts +16 -4
- package/dist/readmore.iife.js +1 -1
- package/dist/readmore.iife.js.map +1 -1
- package/dist/readmore.js +51 -9
- package/dist/readmore.js.map +1 -1
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
Lightweight, framework-agnostic plugin to clamp text to N lines (or a fixed pixel height) with an ellipsis and a
|
|
8
8
|
`Read more` / `Read less` toggle button.
|
|
9
9
|
|
|
10
|
-
- Zero dependencies
|
|
10
|
+
- Zero dependencies
|
|
11
11
|
- TypeScript types included
|
|
12
12
|
- CSS-driven clamping via `-webkit-line-clamp` or `max-height`
|
|
13
13
|
- The toggle button is only created when the text actually overflows
|
|
@@ -111,7 +111,7 @@ const instances = ReadMore.init('.excerpt');
|
|
|
111
111
|
expandAllButton.addEventListener('click', () => instances.forEach((instance) => instance.toggle(true)));
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
-
The clamp is re-measured when the element resizes and
|
|
114
|
+
The clamp is re-measured when the element resizes and each time web fonts finish loading. Content changes that keep the
|
|
115
115
|
element's size are not detected: call `refresh()` so the button is mounted or removed as needed:
|
|
116
116
|
|
|
117
117
|
```ts
|
|
@@ -158,8 +158,11 @@ return (
|
|
|
158
158
|
| `expandedClass` | `string` | `'is-expanded'` | Class applied to the target element while expanded. |
|
|
159
159
|
| `openingClass` | `string` | `'is-opening'` | Transient class applied while expanding, removed after transition. |
|
|
160
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. |
|
|
161
162
|
| `onToggle` | `(expanded: boolean, instance: ReadMore) => void` | — | Callback fired after each toggle, before the `readmore:toggle` event (still dispatched if it throws). |
|
|
162
163
|
|
|
164
|
+
For `expandedClass`, `openingClass` and `closingClass`, an empty string falls back to the default.
|
|
165
|
+
|
|
163
166
|
## API
|
|
164
167
|
|
|
165
168
|
```ts
|
|
@@ -183,14 +186,20 @@ ReadMore.getInstance(element); // returns the ReadMore bound to an eleme
|
|
|
183
186
|
|
|
184
187
|
The constructor throws instead of failing silently:
|
|
185
188
|
|
|
186
|
-
| Error | When
|
|
187
|
-
|
|
188
|
-
| `TypeError` | `element` is not an `HTMLElement`.
|
|
189
|
-
| `Error` | The element is already initialized — call `destroy()` before re-initializing.
|
|
190
|
-
| `RangeError` | `lines` is not an integer `>= 1`, or `height` is not a finite number `> 0`.
|
|
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. |
|
|
191
195
|
|
|
192
196
|
A `buttonContainer` that is not an `HTMLElement` also throws a `TypeError`.
|
|
193
197
|
|
|
198
|
+
### Server-side rendering
|
|
199
|
+
|
|
200
|
+
Importing the module is safe without a browser. There, `ReadMore.init()` is a no-op that returns `[]`, and the
|
|
201
|
+
constructor throws a `TypeError` (no `HTMLElement`).
|
|
202
|
+
|
|
194
203
|
`lines` and `height` also accept numeric strings such as `'3'` (e.g. read from a `data-*` attribute); blank or
|
|
195
204
|
non-numeric strings throw a `RangeError`.
|
|
196
205
|
|
|
@@ -215,7 +224,8 @@ new ReadMore(el, {
|
|
|
215
224
|
The plugin sets a CSS custom property on the target element and toggles `.readmore-clamp`. In **lines mode** (default),
|
|
216
225
|
it sets `--readmore-lines` and the bundled stylesheet applies `-webkit-line-clamp`, producing an ellipsis at the end of
|
|
217
226
|
the last visible line. In **height mode** (`height` option), it sets `--readmore-height` and applies `max-height` via
|
|
218
|
-
the `.readmore-clamp--height` modifier class instead.
|
|
227
|
+
the `.readmore-clamp--height` modifier class instead. When expanding in height mode, it also sets `--readmore-full-height`
|
|
228
|
+
(the full content height in `px`), handy as the `max-height` target of an animation. The toggle button is mounted (right after the element, or inside
|
|
219
229
|
`buttonContainer` when set) only when the content actually overflows; a single `ResizeObserver`, shared by every instance, watches the elements and unmounts the
|
|
220
230
|
button again if the text ends up fitting (or mounts it later if it starts overflowing). All resized elements are
|
|
221
231
|
measured before any button is added or removed, to avoid a layout per element.
|
|
@@ -270,3 +280,6 @@ the `--readmore-fade-size` custom property:
|
|
|
270
280
|
For rich content, prefer height mode.
|
|
271
281
|
- Lines mode cannot be animated: neither `display` nor `line-clamp` can transition. For an animated expand/collapse, use
|
|
272
282
|
height mode with a `max-height` transition.
|
|
283
|
+
- In lines mode on Safari/WebKit, `-webkit-line-clamp` places hidden inline elements inside the visible area, so the
|
|
284
|
+
automatic expansion on focus does not trigger. It works in height mode on all browsers, and in lines mode on Chromium
|
|
285
|
+
and Firefox. For focusable content (links, buttons) in clamped text, prefer height mode.
|
package/dist/readmore.d.ts
CHANGED
|
@@ -75,6 +75,13 @@ export interface ReadMoreOptions {
|
|
|
75
75
|
* @defaultValue 'is-closing'
|
|
76
76
|
*/
|
|
77
77
|
closingClass?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Scrolls the element back into view when collapsing, if its top edge ended
|
|
80
|
+
* up above the viewport.
|
|
81
|
+
*
|
|
82
|
+
* @defaultValue false
|
|
83
|
+
*/
|
|
84
|
+
scrollOnCollapse?: boolean;
|
|
78
85
|
/**
|
|
79
86
|
* Called after each toggle with the resulting state, right before the
|
|
80
87
|
* `readmore:toggle` event is dispatched.
|
|
@@ -108,7 +115,8 @@ declare global {
|
|
|
108
115
|
/**
|
|
109
116
|
* Clamps an element to a number of lines (or a fixed height) and mounts a
|
|
110
117
|
* `Read more` / `Read less` button right after it, but only while the content
|
|
111
|
-
* actually overflows.
|
|
118
|
+
* actually overflows. Focus landing on an element in the hidden part expands
|
|
119
|
+
* the text.
|
|
112
120
|
*
|
|
113
121
|
* @example
|
|
114
122
|
* ```ts
|
|
@@ -127,11 +135,14 @@ export declare class ReadMore {
|
|
|
127
135
|
/**
|
|
128
136
|
* @param element - The element to clamp.
|
|
129
137
|
* @param options - See {@link ReadMoreOptions}.
|
|
130
|
-
* @throws TypeError - if `element` is not an `HTMLElement
|
|
138
|
+
* @throws TypeError - if `element` is not an `HTMLElement`, or there is no
|
|
139
|
+
* `HTMLElement` at all (no browser, e.g. SSR).
|
|
131
140
|
* @throws Error - if `element` is already initialized; call
|
|
132
141
|
* {@link ReadMore.destroy} first.
|
|
133
142
|
* @throws RangeError - if `lines` is not an integer greater than or equal
|
|
134
143
|
* to 1, or `height` is not a finite number greater than 0.
|
|
144
|
+
* @throws RangeError - if `expandedClass`, `openingClass` or `closingClass`
|
|
145
|
+
* is not a string or contains whitespace (an empty string means the default).
|
|
135
146
|
* @throws TypeError - if `buttonContainer` is set but is not an `HTMLElement`.
|
|
136
147
|
*/
|
|
137
148
|
constructor(element: HTMLElement, options?: ReadMoreOptions);
|
|
@@ -144,8 +155,9 @@ export declare class ReadMore {
|
|
|
144
155
|
* of elements (`NodeList`, `HTMLCollection`, array, `Set`…).
|
|
145
156
|
* @param options - See {@link ReadMoreOptions}. Not applied to elements
|
|
146
157
|
* that are already initialized.
|
|
147
|
-
* @param root - Where to look for `target` when it is a selector.
|
|
148
|
-
* @returns One instance per element, in document order
|
|
158
|
+
* @param root - Where to look for `target` when it is a selector. Defaults to `document`.
|
|
159
|
+
* @returns One instance per element, in document order; `[]` without a
|
|
160
|
+
* browser (e.g. SSR).
|
|
149
161
|
*/
|
|
150
162
|
static init(target: string | HTMLElement | Iterable<HTMLElement> | ArrayLike<HTMLElement>, options?: ReadMoreOptions, root?: ParentNode): ReadMore[];
|
|
151
163
|
/** Whether the text is currently expanded. */
|
package/dist/readmore.iife.js
CHANGED
|
@@ -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
|
|
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||{});
|
|
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","#destroyed","element","options","entry","key","value","buttonContainer","height","lines","#init","nodes","target","root","batch","n","#syncAll","#expanded","expanded","force","#updateButton","stateClass","otherClass","#applyTransientClass","#sync","#isOverflowing","#transientTimer","#unmountButton","#generatedId","#onResize","entries","measured","instance","overflowing","#refreshOnFonts","queue","#button","#mountButton","timer","running","a","done","#transitionTime","style","durations","delays","max","i","id","btn","#onClick","label","lessLabel","moreLabel","lessText","moreText","el"],"sources":["../src/readmore.ts"],"sourcesContent":["/**\n * Options accepted by {@link ReadMore}. Every field is optional.\n */\nexport interface ReadMoreOptions {\n /**\n * Number of lines to clamp to. Ignored when {@link ReadMoreOptions.height} is set.\n *\n * @defaultValue 3\n * @throws RangeError - if not an integer greater than or equal to 1.\n */\n lines?: number;\n\n /**\n * Max height in pixels. Takes precedence over {@link ReadMoreOptions.lines}\n * and clamps with `max-height` instead of `-webkit-line-clamp`.\n *\n * @throws RangeError - if not a finite number greater than 0.\n */\n height?: number;\n\n /**\n * Label of the toggle button while the text is collapsed.\n *\n * @defaultValue 'Read more'\n */\n moreText?: string;\n\n /**\n * Label of the toggle button while the text is expanded.\n *\n * @defaultValue 'Read less'\n */\n lessText?: string;\n\n /**\n * Accessible name (`aria-label`) of the toggle button while the text is\n * collapsed, e.g. `'Read more about Product A'` when several buttons share\n * the same visible text. It should contain {@link ReadMoreOptions.moreText}.\n */\n moreLabel?: string;\n\n /**\n * Accessible name (`aria-label`) of the toggle button while the text is\n * expanded. It should contain {@link ReadMoreOptions.lessText}.\n */\n lessLabel?: string;\n\n /**\n * Class applied to the toggle button.\n *\n * @defaultValue 'readmore-btn'\n */\n buttonClass?: string;\n\n /**\n * Element the toggle button is appended to, instead of being inserted right\n * after the clamped element. Use an element owned by your framework (a ref)\n * so reconciliation cannot drop the button.\n *\n * @throws TypeError - if not an `HTMLElement`.\n */\n buttonContainer?: HTMLElement;\n\n /**\n * Class applied to the target element for the whole expanded state.\n *\n * @defaultValue 'is-expanded'\n */\n expandedClass?: string;\n\n /**\n * Transient class applied when expanding, removed once the longest CSS\n * transition on the element (duration + delay) has elapsed — or on the next\n * tick if no transition is declared.\n *\n * @defaultValue 'is-opening'\n */\n openingClass?: string;\n\n /**\n * Transient class applied when collapsing, removed once the longest CSS\n * transition on the element (duration + delay) has elapsed — or on the next\n * tick if no transition is declared.\n *\n * @defaultValue 'is-closing'\n */\n closingClass?: string;\n\n /**\n * Called after each toggle with the resulting state, right before the\n * `readmore:toggle` event is dispatched.\n *\n * @param expanded - `true` once expanded, `false` once collapsed.\n * @param instance - The instance that was toggled.\n */\n onToggle?: (expanded: boolean, instance: ReadMore) => void;\n}\n\ntype OptionalKeys = 'height' | 'buttonContainer' | 'moreLabel' | 'lessLabel' | 'onToggle';\n\n/**\n * {@link ReadMoreOptions} merged with the defaults, as exposed on\n * {@link ReadMore.options}.\n */\nexport type ResolvedOptions = Required<Omit<ReadMoreOptions, OptionalKeys>> & Pick<ReadMoreOptions, OptionalKeys>;\n\n/** `detail` of the `readmore:toggle` event. */\nexport interface ReadMoreToggleDetail {\n /** `true` once expanded, `false` once collapsed. */\n expanded: boolean;\n\n /** The instance that was toggled. */\n instance: ReadMore;\n}\n\n// Types `event.detail` in `addEventListener('readmore:toggle', …)`, on elements\n// and on `document` (the event bubbles).\ndeclare global {\n interface HTMLElementEventMap {\n 'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;\n }\n\n interface DocumentEventMap {\n 'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;\n }\n}\n\nconst CLAMP_CLASS = 'readmore-clamp';\nconst CLAMP_HEIGHT_CLASS = 'readmore-clamp--height';\n\nconst instances = new WeakMap<HTMLElement, ReadMore>();\nlet idCounter = 0;\n\nconst DEFAULTS: ResolvedOptions = {\n lines: 3,\n moreText: 'Read more',\n lessText: 'Read less',\n buttonClass: 'readmore-btn',\n expandedClass: 'is-expanded',\n openingClass: 'is-opening',\n closingClass: 'is-closing'\n};\n\n/**\n * Parses a computed `transition-duration` / `transition-delay` list into\n * milliseconds. Computed times are always serialized in seconds.\n */\nfunction toMs(list: string): number[] {\n return list.split(',').map((v) => parseFloat(v) * 1000 || 0);\n}\n\n/**\n * Clamps an element to a number of lines (or a fixed height) and mounts a\n * `Read more` / `Read less` button right after it, but only while the content\n * actually overflows.\n *\n * @example\n * ```ts\n * import { ReadMore } from '@frontfactory/readmore';\n * import '@frontfactory/readmore/style.css';\n *\n * ReadMore.init('.excerpt', { lines: 3 });\n * ```\n */\nexport class ReadMore {\n /** Shared by every instance, created with the first one. */\n static #resizeObserver: ResizeObserver | undefined;\n\n /** Instances created by the running {@link ReadMore.init}, synced together once all exist. */\n static #batch: ReadMore[] | null = null;\n\n /** Instances waiting for `document.fonts.ready`, measured together. */\n static #fontQueue: ReadMore[] | null = null;\n\n /** The clamped element. */\n readonly el: HTMLElement;\n\n /** The options this instance was built with, merged with the defaults. */\n readonly options: Readonly<ResolvedOptions>;\n\n #button: HTMLButtonElement | null = null;\n #expanded = false;\n #generatedId = false;\n #transientTimer: ReturnType<typeof setTimeout> | null = null;\n #onClick = (): void => this.toggle();\n\n // Destroyed once the element is no longer bound to this instance: after\n // destroy(), or if the element was re-initialized by another instance.\n get #destroyed(): boolean {\n return instances.get(this.el) !== this;\n }\n\n /**\n * @param element - The element to clamp.\n * @param options - See {@link ReadMoreOptions}.\n * @throws TypeError - if `element` is not an `HTMLElement`.\n * @throws Error - if `element` is already initialized; call\n * {@link ReadMore.destroy} first.\n * @throws RangeError - if `lines` is not an integer greater than or equal\n * to 1, or `height` is not a finite number greater than 0.\n * @throws TypeError - if `buttonContainer` is set but is not an `HTMLElement`.\n */\n constructor(element: HTMLElement, options: ReadMoreOptions = {}) {\n if (!(element instanceof HTMLElement)) {\n throw new TypeError('ReadMore: an HTMLElement is required.');\n }\n\n if (instances.has(element)) {\n throw new Error('ReadMore: element is already initialized. Call destroy() first.');\n }\n\n this.el = element;\n // Options explicitly set to `undefined` must not override the defaults.\n this.options = {\n ...DEFAULTS,\n ...Object.fromEntries(Object.entries(options).filter((entry) => entry[1] !== undefined))\n };\n\n // Numeric strings (data-* attributes, framework props) are accepted; blank or non-numeric ones still fail below.\n for (const key of [\n 'height',\n 'lines'\n ] as const) {\n const value: unknown = this.options[key];\n\n if (typeof value === 'string' && value.trim() !== '') {\n (this.options as Record<string, unknown>)[key] = Number(value);\n }\n }\n\n const { height, lines, buttonContainer } = this.options;\n\n if (buttonContainer != null && !(buttonContainer instanceof HTMLElement)) {\n throw new TypeError('ReadMore: buttonContainer must be an HTMLElement.');\n }\n\n if (height != null && !(Number.isFinite(height) && height > 0)) {\n throw new RangeError('ReadMore: height must be a finite number greater than 0.');\n }\n\n if (!(Number.isInteger(lines) && lines >= 1)) {\n throw new RangeError('ReadMore: lines must be an integer of at least 1.');\n }\n\n instances.set(element, this);\n this.#init();\n }\n\n /**\n * Instantiates {@link ReadMore} on every matching element. Elements that are\n * already initialized are left untouched and their existing instance is\n * returned, so it is safe to call again after new content is added.\n *\n * @param target - A CSS selector, an element, or any iterable or array-like\n * of elements (`NodeList`, `HTMLCollection`, array, `Set`…).\n * @param options - See {@link ReadMoreOptions}. Not applied to elements\n * that are already initialized.\n * @param root - Where to look for `target` when it is a selector.\n * @returns One instance per element, in document order.\n */\n static init(\n target: string | HTMLElement | Iterable<HTMLElement> | ArrayLike<HTMLElement>,\n options?: ReadMoreOptions,\n root: ParentNode = document\n ): ReadMore[] {\n const nodes =\n typeof target === 'string'\n ? root.querySelectorAll<HTMLElement>(target)\n : target instanceof HTMLElement ? [\n target\n ] : target;\n\n const batch: ReadMore[] = [];\n\n ReadMore.#batch = batch;\n\n try {\n return Array.from(nodes).map((n) => instances.get(n) ?? new ReadMore(n, options));\n } finally {\n ReadMore.#batch = null;\n ReadMore.#syncAll(batch);\n }\n }\n\n /** Whether the text is currently expanded. */\n get expanded(): boolean {\n return this.#expanded;\n }\n\n /**\n * Expands or collapses the text, updating the button label, the\n * `aria-expanded` attribute and the state classes, then calls\n * {@link ReadMoreOptions.onToggle} and dispatches a bubbling\n * `readmore:toggle` event on the element. Does nothing once destroyed.\n *\n * @param force - `true` to expand, `false` to collapse; the state is\n * inverted when omitted. Does nothing if the text is already in the\n * requested state.\n */\n toggle(force?: boolean): void {\n // Only a real boolean forces the state, so `toggle` still works as an\n // event listener (`addEventListener('click', rm.toggle.bind(rm))`).\n const expanded = typeof force === 'boolean' ? force : !this.#expanded;\n\n if (this.#destroyed || expanded === this.#expanded) {\n return;\n }\n\n this.#expanded = expanded;\n this.el.classList.toggle(CLAMP_CLASS, !this.#expanded);\n\n if (this.options.height != null) {\n this.el.classList.toggle(CLAMP_HEIGHT_CLASS, !this.#expanded);\n }\n\n this.el.classList.toggle(this.options.expandedClass, this.#expanded);\n this.#updateButton();\n\n const stateClass = this.#expanded ? this.options.openingClass : this.options.closingClass;\n const otherClass = this.#expanded ? this.options.closingClass : this.options.openingClass;\n\n this.el.classList.remove(otherClass);\n this.#applyTransientClass(stateClass);\n\n try {\n this.options.onToggle?.(this.#expanded, this);\n } finally {\n // The event is dispatched even if `onToggle` throws (the error still propagates).\n this.el.dispatchEvent(new CustomEvent<ReadMoreToggleDetail>('readmore:toggle', {\n bubbles: true,\n detail: {\n expanded: this.#expanded,\n instance: this\n }\n }));\n }\n }\n\n /**\n * Checks again whether the content overflows, mounting or unmounting the\n * button. Resizes of the element are already tracked; call it after\n * changing the content in a way that keeps the element's size. Does nothing\n * while expanded, during a toggle transition, or once destroyed.\n */\n refresh(): void {\n this.#sync(this.#isOverflowing());\n }\n\n /**\n * @param element - An element possibly initialized by this plugin.\n * @returns The instance bound to it, or `undefined`.\n */\n static getInstance(element: HTMLElement): ReadMore | undefined {\n return instances.get(element);\n }\n\n /**\n * Restores the element: removes the button, the classes, the CSS custom\n * properties, the listeners and the generated `id`. The element can then be\n * initialized again. Calling it again does nothing.\n */\n destroy(): void {\n if (this.#destroyed) {\n return;\n }\n\n instances.delete(this.el);\n ReadMore.#resizeObserver?.unobserve(this.el);\n\n if (this.#transientTimer !== null) {\n clearTimeout(this.#transientTimer);\n this.#transientTimer = null;\n }\n\n this.#unmountButton();\n this.el.classList.remove(\n CLAMP_CLASS,\n CLAMP_HEIGHT_CLASS,\n this.options.expandedClass,\n this.options.openingClass,\n this.options.closingClass\n );\n\n this.el.style.removeProperty('--readmore-lines');\n this.el.style.removeProperty('--readmore-height');\n\n if (this.#generatedId) {\n this.el.removeAttribute('id');\n this.#generatedId = false;\n }\n }\n\n // Measures every resized element before touching the DOM, so mounting a\n // button does not force a new layout for each following element.\n static #onResize(entries: ResizeObserverEntry[]): void {\n ReadMore.#syncAll(entries.map((entry) => instances.get(entry.target as HTMLElement)));\n }\n\n static #syncAll(list: (ReadMore | undefined)[]): void {\n const measured = new Map<ReadMore, boolean>();\n\n for (const instance of list) {\n if (instance) {\n measured.set(instance, instance.#isOverflowing());\n }\n }\n\n measured.forEach((overflowing, instance) => instance.#sync(overflowing));\n }\n\n #init(): void {\n if (this.options.height != null) {\n this.el.style.setProperty('--readmore-height', `${ this.options.height }px`);\n this.el.classList.add(CLAMP_CLASS, CLAMP_HEIGHT_CLASS);\n } else {\n this.el.style.setProperty('--readmore-lines', String(this.options.lines));\n this.el.classList.add(CLAMP_CLASS);\n }\n\n ReadMore.#resizeObserver ??= new ResizeObserver(ReadMore.#onResize);\n ReadMore.#resizeObserver.observe(this.el);\n\n // Inside init(), the batch measures all elements at once afterwards.\n if (ReadMore.#batch) {\n ReadMore.#batch.push(this);\n } else {\n this.refresh();\n }\n\n this.#refreshOnFonts();\n }\n\n // A font swap can change the overflow without resizing the element, which the\n // ResizeObserver would miss. Instances created in the same tick share one\n // measurement; destroyed ones are skipped by #sync.\n #refreshOnFonts(): void {\n if (typeof document === 'undefined' || !document.fonts) {\n return;\n }\n\n if (ReadMore.#fontQueue) {\n ReadMore.#fontQueue.push(this);\n\n return;\n }\n\n const queue: ReadMore[] = ReadMore.#fontQueue = [\n this\n ];\n\n void document.fonts.ready.then(() => {\n ReadMore.#fontQueue = null;\n ReadMore.#syncAll(queue.filter((instance) => !instance.#destroyed));\n });\n }\n\n #isOverflowing(): boolean {\n return this.el.scrollHeight - this.el.clientHeight > 1;\n }\n\n #sync(overflowing: boolean): void {\n // While a toggle transition runs, the element's size is not final: measuring\n // it mid-collapse would remove the button. The timer refreshes once it ends.\n if (this.#expanded || this.#transientTimer !== null || this.#destroyed) {\n return;\n }\n\n if (overflowing && !this.#button) {\n this.#mountButton();\n } else if (!overflowing && this.#button) {\n this.#unmountButton();\n }\n }\n\n #applyTransientClass(stateClass: string): void {\n if (this.#transientTimer !== null) {\n clearTimeout(this.#transientTimer);\n }\n\n this.el.classList.add(stateClass);\n\n // A timer rather than `transitionend`, which never fires when no declared\n // property actually changes (e.g. `transition: all` in lines mode).\n const timer = setTimeout(() => {\n // Some engines (WebKit) start the transition later than the timer assumes:\n // wait for it to end, or the size is measured mid-way and the button remounted.\n const running = this.el.getAnimations?.().filter(a => a instanceof CSSTransition) ?? [];\n const done = () => {\n // Superseded by another toggle, or destroyed meanwhile.\n if (this.#transientTimer !== timer) {\n return;\n }\n\n this.#transientTimer = null;\n this.el.classList.remove(stateClass);\n this.refresh();\n };\n\n if (running.length) {\n Promise.allSettled(running.map(a => a.finished)).then(done);\n } else {\n done();\n }\n }, this.#transitionTime());\n\n this.#transientTimer = timer;\n }\n\n /** Longest `duration + delay` among the transitions declared on the element, in ms. */\n #transitionTime(): number {\n const style = getComputedStyle(this.el);\n const durations = toMs(style.transitionDuration);\n const delays = toMs(style.transitionDelay);\n let max = 0;\n\n // Shorter lists repeat to match the longer one, as CSS does.\n for (let i = 0; i < Math.max(durations.length, delays.length); i++) {\n max = Math.max(max, durations[i % durations.length] + delays[i % delays.length]);\n }\n\n return max;\n }\n\n #mountButton(): void {\n if (!this.el.id) {\n let id: string;\n\n // Skip ids already used in the page (server-rendered markup, another copy of the plugin…).\n do {\n id = `readmore-${++idCounter}`;\n } while (document.getElementById(id));\n\n this.el.id = id;\n this.#generatedId = true;\n }\n\n const btn = document.createElement('button');\n\n btn.type = 'button';\n btn.className = this.options.buttonClass;\n btn.setAttribute('aria-controls', this.el.id);\n btn.addEventListener('click', this.#onClick);\n this.#button = btn;\n this.#updateButton();\n\n if (this.options.buttonContainer) {\n this.options.buttonContainer.appendChild(btn);\n } else {\n this.el.insertAdjacentElement('afterend', btn);\n }\n }\n\n /** Syncs the button's text, `aria-expanded` and `aria-label` with the state. */\n #updateButton(): void {\n if (!this.#button) {\n return;\n }\n\n const { moreText, lessText, moreLabel, lessLabel } = this.options;\n const label = this.#expanded ? lessLabel : moreLabel;\n\n this.#button.textContent = this.#expanded ? lessText : moreText;\n this.#button.setAttribute('aria-expanded', String(this.#expanded));\n\n if (label) {\n this.#button.setAttribute('aria-label', label);\n } else {\n this.#button.removeAttribute('aria-label');\n }\n }\n\n #unmountButton(): void {\n if (!this.#button) {\n return;\n }\n\n this.#button.removeEventListener('click', this.#onClick);\n\n // Removing the focused button would drop focus to <body>: keep it on the element.\n if (document.activeElement === this.#button) {\n const { el } = this;\n\n if (!el.hasAttribute('tabindex')) {\n el.tabIndex = -1;\n el.addEventListener('blur', () => el.removeAttribute('tabindex'), {\n once: true\n });\n }\n\n el.focus();\n }\n\n this.#button.remove();\n this.#button = null;\n }\n}\n"],"mappings":"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"}
|
|
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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"}
|
package/dist/readmore.js
CHANGED
|
@@ -10,7 +10,8 @@ var DEFAULTS = {
|
|
|
10
10
|
buttonClass: "readmore-btn",
|
|
11
11
|
expandedClass: "is-expanded",
|
|
12
12
|
openingClass: "is-opening",
|
|
13
|
-
closingClass: "is-closing"
|
|
13
|
+
closingClass: "is-closing",
|
|
14
|
+
scrollOnCollapse: false
|
|
14
15
|
};
|
|
15
16
|
/**
|
|
16
17
|
* Parses a computed `transition-duration` / `transition-delay` list into
|
|
@@ -22,7 +23,8 @@ function toMs(list) {
|
|
|
22
23
|
/**
|
|
23
24
|
* Clamps an element to a number of lines (or a fixed height) and mounts a
|
|
24
25
|
* `Read more` / `Read less` button right after it, but only while the content
|
|
25
|
-
* actually overflows.
|
|
26
|
+
* actually overflows. Focus landing on an element in the hidden part expands
|
|
27
|
+
* the text.
|
|
26
28
|
*
|
|
27
29
|
* @example
|
|
28
30
|
* ```ts
|
|
@@ -39,6 +41,10 @@ var ReadMore = class ReadMore {
|
|
|
39
41
|
static #batch = null;
|
|
40
42
|
/** Instances waiting for `document.fonts.ready`, measured together. */
|
|
41
43
|
static #fontQueue = null;
|
|
44
|
+
/** Live instances resynced by the shared `document.fonts` `loadingdone` listener. */
|
|
45
|
+
static #live = /* @__PURE__ */ new Set();
|
|
46
|
+
/** The `document.fonts` the shared listener is attached to. */
|
|
47
|
+
static #fonts;
|
|
42
48
|
/** The clamped element. */
|
|
43
49
|
el;
|
|
44
50
|
/** The options this instance was built with, merged with the defaults. */
|
|
@@ -48,26 +54,34 @@ var ReadMore = class ReadMore {
|
|
|
48
54
|
#generatedId = false;
|
|
49
55
|
#transientTimer = null;
|
|
50
56
|
#onClick = () => this.toggle();
|
|
57
|
+
#onFocusIn = (e) => {
|
|
58
|
+
const { target } = e;
|
|
59
|
+
if (this.#expanded || !this.#button || target === this.el || !(target instanceof Element)) return;
|
|
60
|
+
if (target.getBoundingClientRect().bottom + this.el.scrollTop > this.el.getBoundingClientRect().bottom + 1) this.toggle(true);
|
|
61
|
+
};
|
|
51
62
|
get #destroyed() {
|
|
52
63
|
return instances.get(this.el) !== this;
|
|
53
64
|
}
|
|
54
65
|
/**
|
|
55
66
|
* @param element - The element to clamp.
|
|
56
67
|
* @param options - See {@link ReadMoreOptions}.
|
|
57
|
-
* @throws TypeError - if `element` is not an `HTMLElement
|
|
68
|
+
* @throws TypeError - if `element` is not an `HTMLElement`, or there is no
|
|
69
|
+
* `HTMLElement` at all (no browser, e.g. SSR).
|
|
58
70
|
* @throws Error - if `element` is already initialized; call
|
|
59
71
|
* {@link ReadMore.destroy} first.
|
|
60
72
|
* @throws RangeError - if `lines` is not an integer greater than or equal
|
|
61
73
|
* to 1, or `height` is not a finite number greater than 0.
|
|
74
|
+
* @throws RangeError - if `expandedClass`, `openingClass` or `closingClass`
|
|
75
|
+
* is not a string or contains whitespace (an empty string means the default).
|
|
62
76
|
* @throws TypeError - if `buttonContainer` is set but is not an `HTMLElement`.
|
|
63
77
|
*/
|
|
64
78
|
constructor(element, options = {}) {
|
|
65
|
-
if (!(element instanceof HTMLElement)) throw new TypeError("ReadMore: an HTMLElement is required.");
|
|
79
|
+
if (typeof HTMLElement === "undefined" || !(element instanceof HTMLElement)) throw new TypeError("ReadMore: an HTMLElement is required.");
|
|
66
80
|
if (instances.has(element)) throw new Error("ReadMore: element is already initialized. Call destroy() first.");
|
|
67
81
|
this.el = element;
|
|
68
82
|
this.options = {
|
|
69
83
|
...DEFAULTS,
|
|
70
|
-
...Object.fromEntries(Object.entries(options).filter((entry) => entry[1] !== void 0))
|
|
84
|
+
...Object.fromEntries(Object.entries(options).filter((entry) => entry[1] !== void 0 && !(entry[1] === "" && entry[0].endsWith("Class"))))
|
|
71
85
|
};
|
|
72
86
|
for (const key of ["height", "lines"]) {
|
|
73
87
|
const value = this.options[key];
|
|
@@ -77,6 +91,14 @@ var ReadMore = class ReadMore {
|
|
|
77
91
|
if (buttonContainer != null && !(buttonContainer instanceof HTMLElement)) throw new TypeError("ReadMore: buttonContainer must be an HTMLElement.");
|
|
78
92
|
if (height != null && !(Number.isFinite(height) && height > 0)) throw new RangeError("ReadMore: height must be a finite number greater than 0.");
|
|
79
93
|
if (!(Number.isInteger(lines) && lines >= 1)) throw new RangeError("ReadMore: lines must be an integer of at least 1.");
|
|
94
|
+
for (const key of [
|
|
95
|
+
"expandedClass",
|
|
96
|
+
"openingClass",
|
|
97
|
+
"closingClass"
|
|
98
|
+
]) {
|
|
99
|
+
const value = this.options[key];
|
|
100
|
+
if (typeof value !== "string" || !/^\S+$/.test(value)) throw new RangeError(`ReadMore: ${key} must be a string without whitespace.`);
|
|
101
|
+
}
|
|
80
102
|
instances.set(element, this);
|
|
81
103
|
this.#init();
|
|
82
104
|
}
|
|
@@ -89,11 +111,13 @@ var ReadMore = class ReadMore {
|
|
|
89
111
|
* of elements (`NodeList`, `HTMLCollection`, array, `Set`…).
|
|
90
112
|
* @param options - See {@link ReadMoreOptions}. Not applied to elements
|
|
91
113
|
* that are already initialized.
|
|
92
|
-
* @param root - Where to look for `target` when it is a selector.
|
|
93
|
-
* @returns One instance per element, in document order
|
|
114
|
+
* @param root - Where to look for `target` when it is a selector. Defaults to `document`.
|
|
115
|
+
* @returns One instance per element, in document order; `[]` without a
|
|
116
|
+
* browser (e.g. SSR).
|
|
94
117
|
*/
|
|
95
|
-
static init(target, options, root
|
|
96
|
-
|
|
118
|
+
static init(target, options, root) {
|
|
119
|
+
if (typeof HTMLElement === "undefined" || typeof document === "undefined") return [];
|
|
120
|
+
const nodes = typeof target === "string" ? (root ?? document).querySelectorAll(target) : target instanceof HTMLElement ? [target] : target;
|
|
97
121
|
const batch = [];
|
|
98
122
|
ReadMore.#batch = batch;
|
|
99
123
|
try {
|
|
@@ -120,6 +144,7 @@ var ReadMore = class ReadMore {
|
|
|
120
144
|
toggle(force) {
|
|
121
145
|
const expanded = typeof force === "boolean" ? force : !this.#expanded;
|
|
122
146
|
if (this.#destroyed || expanded === this.#expanded) return;
|
|
147
|
+
if (expanded && this.options.height != null) this.el.style.setProperty("--readmore-full-height", `${this.el.scrollHeight}px`);
|
|
123
148
|
this.#expanded = expanded;
|
|
124
149
|
this.el.classList.toggle(CLAMP_CLASS, !this.#expanded);
|
|
125
150
|
if (this.options.height != null) this.el.classList.toggle(CLAMP_HEIGHT_CLASS, !this.#expanded);
|
|
@@ -129,6 +154,7 @@ var ReadMore = class ReadMore {
|
|
|
129
154
|
const otherClass = this.#expanded ? this.options.closingClass : this.options.openingClass;
|
|
130
155
|
this.el.classList.remove(otherClass);
|
|
131
156
|
this.#applyTransientClass(stateClass);
|
|
157
|
+
if (!this.#expanded && this.options.scrollOnCollapse && this.el.getBoundingClientRect().top < 0) this.el.scrollIntoView?.({ block: "nearest" });
|
|
132
158
|
try {
|
|
133
159
|
this.options.onToggle?.(this.#expanded, this);
|
|
134
160
|
} finally {
|
|
@@ -165,7 +191,12 @@ var ReadMore = class ReadMore {
|
|
|
165
191
|
destroy() {
|
|
166
192
|
if (this.#destroyed) return;
|
|
167
193
|
instances.delete(this.el);
|
|
194
|
+
this.el.removeEventListener("focusin", this.#onFocusIn);
|
|
168
195
|
ReadMore.#resizeObserver?.unobserve(this.el);
|
|
196
|
+
if (ReadMore.#live.delete(this) && ReadMore.#live.size === 0) {
|
|
197
|
+
ReadMore.#fonts?.removeEventListener?.("loadingdone", ReadMore.#onFontsLoaded);
|
|
198
|
+
ReadMore.#fonts = void 0;
|
|
199
|
+
}
|
|
169
200
|
if (this.#transientTimer !== null) {
|
|
170
201
|
clearTimeout(this.#transientTimer);
|
|
171
202
|
this.#transientTimer = null;
|
|
@@ -174,6 +205,7 @@ var ReadMore = class ReadMore {
|
|
|
174
205
|
this.el.classList.remove(CLAMP_CLASS, CLAMP_HEIGHT_CLASS, this.options.expandedClass, this.options.openingClass, this.options.closingClass);
|
|
175
206
|
this.el.style.removeProperty("--readmore-lines");
|
|
176
207
|
this.el.style.removeProperty("--readmore-height");
|
|
208
|
+
this.el.style.removeProperty("--readmore-full-height");
|
|
177
209
|
if (this.#generatedId) {
|
|
178
210
|
this.el.removeAttribute("id");
|
|
179
211
|
this.#generatedId = false;
|
|
@@ -182,6 +214,9 @@ var ReadMore = class ReadMore {
|
|
|
182
214
|
static #onResize(entries) {
|
|
183
215
|
ReadMore.#syncAll(entries.map((entry) => instances.get(entry.target)));
|
|
184
216
|
}
|
|
217
|
+
static #onFontsLoaded = () => {
|
|
218
|
+
ReadMore.#syncAll(Array.from(ReadMore.#live));
|
|
219
|
+
};
|
|
185
220
|
static #syncAll(list) {
|
|
186
221
|
const measured = /* @__PURE__ */ new Map();
|
|
187
222
|
for (const instance of list) if (instance) measured.set(instance, instance.#isOverflowing());
|
|
@@ -195,6 +230,7 @@ var ReadMore = class ReadMore {
|
|
|
195
230
|
this.el.style.setProperty("--readmore-lines", String(this.options.lines));
|
|
196
231
|
this.el.classList.add(CLAMP_CLASS);
|
|
197
232
|
}
|
|
233
|
+
this.el.addEventListener("focusin", this.#onFocusIn);
|
|
198
234
|
ReadMore.#resizeObserver ??= new ResizeObserver(ReadMore.#onResize);
|
|
199
235
|
ReadMore.#resizeObserver.observe(this.el);
|
|
200
236
|
if (ReadMore.#batch) ReadMore.#batch.push(this);
|
|
@@ -203,6 +239,12 @@ var ReadMore = class ReadMore {
|
|
|
203
239
|
}
|
|
204
240
|
#refreshOnFonts() {
|
|
205
241
|
if (typeof document === "undefined" || !document.fonts) return;
|
|
242
|
+
if (ReadMore.#fonts !== document.fonts) {
|
|
243
|
+
ReadMore.#fonts?.removeEventListener?.("loadingdone", ReadMore.#onFontsLoaded);
|
|
244
|
+
ReadMore.#fonts = document.fonts;
|
|
245
|
+
ReadMore.#fonts.addEventListener?.("loadingdone", ReadMore.#onFontsLoaded);
|
|
246
|
+
}
|
|
247
|
+
ReadMore.#live.add(this);
|
|
206
248
|
if (ReadMore.#fontQueue) {
|
|
207
249
|
ReadMore.#fontQueue.push(this);
|
|
208
250
|
return;
|
package/dist/readmore.js.map
CHANGED
|
@@ -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 * Called after each toggle with the resulting state, right before the\n * `readmore:toggle` event is dispatched.\n *\n * @param expanded - `true` once expanded, `false` once collapsed.\n * @param instance - The instance that was toggled.\n */\n onToggle?: (expanded: boolean, instance: ReadMore) => void;\n}\n\ntype OptionalKeys = 'height' | 'buttonContainer' | 'moreLabel' | 'lessLabel' | 'onToggle';\n\n/**\n * {@link ReadMoreOptions} merged with the defaults, as exposed on\n * {@link ReadMore.options}.\n */\nexport type ResolvedOptions = Required<Omit<ReadMoreOptions, OptionalKeys>> & Pick<ReadMoreOptions, OptionalKeys>;\n\n/** `detail` of the `readmore:toggle` event. */\nexport interface ReadMoreToggleDetail {\n /** `true` once expanded, `false` once collapsed. */\n expanded: boolean;\n\n /** The instance that was toggled. */\n instance: ReadMore;\n}\n\n// Types `event.detail` in `addEventListener('readmore:toggle', …)`, on elements\n// and on `document` (the event bubbles).\ndeclare global {\n interface HTMLElementEventMap {\n 'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;\n }\n\n interface DocumentEventMap {\n 'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;\n }\n}\n\nconst CLAMP_CLASS = 'readmore-clamp';\nconst CLAMP_HEIGHT_CLASS = 'readmore-clamp--height';\n\nconst instances = new WeakMap<HTMLElement, ReadMore>();\nlet idCounter = 0;\n\nconst DEFAULTS: ResolvedOptions = {\n lines: 3,\n moreText: 'Read more',\n lessText: 'Read less',\n buttonClass: 'readmore-btn',\n expandedClass: 'is-expanded',\n openingClass: 'is-opening',\n closingClass: 'is-closing'\n};\n\n/**\n * Parses a computed `transition-duration` / `transition-delay` list into\n * milliseconds. Computed times are always serialized in seconds.\n */\nfunction toMs(list: string): number[] {\n return list.split(',').map((v) => parseFloat(v) * 1000 || 0);\n}\n\n/**\n * Clamps an element to a number of lines (or a fixed height) and mounts a\n * `Read more` / `Read less` button right after it, but only while the content\n * actually overflows.\n *\n * @example\n * ```ts\n * import { ReadMore } from '@frontfactory/readmore';\n * import '@frontfactory/readmore/style.css';\n *\n * ReadMore.init('.excerpt', { lines: 3 });\n * ```\n */\nexport class ReadMore {\n /** Shared by every instance, created with the first one. */\n static #resizeObserver: ResizeObserver | undefined;\n\n /** Instances created by the running {@link ReadMore.init}, synced together once all exist. */\n static #batch: ReadMore[] | null = null;\n\n /** Instances waiting for `document.fonts.ready`, measured together. */\n static #fontQueue: ReadMore[] | null = null;\n\n /** The clamped element. */\n readonly el: HTMLElement;\n\n /** The options this instance was built with, merged with the defaults. */\n readonly options: Readonly<ResolvedOptions>;\n\n #button: HTMLButtonElement | null = null;\n #expanded = false;\n #generatedId = false;\n #transientTimer: ReturnType<typeof setTimeout> | null = null;\n #onClick = (): void => this.toggle();\n\n // Destroyed once the element is no longer bound to this instance: after\n // destroy(), or if the element was re-initialized by another instance.\n get #destroyed(): boolean {\n return instances.get(this.el) !== this;\n }\n\n /**\n * @param element - The element to clamp.\n * @param options - See {@link ReadMoreOptions}.\n * @throws TypeError - if `element` is not an `HTMLElement`.\n * @throws Error - if `element` is already initialized; call\n * {@link ReadMore.destroy} first.\n * @throws RangeError - if `lines` is not an integer greater than or equal\n * to 1, or `height` is not a finite number greater than 0.\n * @throws TypeError - if `buttonContainer` is set but is not an `HTMLElement`.\n */\n constructor(element: HTMLElement, options: ReadMoreOptions = {}) {\n if (!(element instanceof HTMLElement)) {\n throw new TypeError('ReadMore: an HTMLElement is required.');\n }\n\n if (instances.has(element)) {\n throw new Error('ReadMore: element is already initialized. Call destroy() first.');\n }\n\n this.el = element;\n // Options explicitly set to `undefined` must not override the defaults.\n this.options = {\n ...DEFAULTS,\n ...Object.fromEntries(Object.entries(options).filter((entry) => entry[1] !== undefined))\n };\n\n // Numeric strings (data-* attributes, framework props) are accepted; blank or non-numeric ones still fail below.\n for (const key of [\n 'height',\n 'lines'\n ] as const) {\n const value: unknown = this.options[key];\n\n if (typeof value === 'string' && value.trim() !== '') {\n (this.options as Record<string, unknown>)[key] = Number(value);\n }\n }\n\n const { height, lines, buttonContainer } = this.options;\n\n if (buttonContainer != null && !(buttonContainer instanceof HTMLElement)) {\n throw new TypeError('ReadMore: buttonContainer must be an HTMLElement.');\n }\n\n if (height != null && !(Number.isFinite(height) && height > 0)) {\n throw new RangeError('ReadMore: height must be a finite number greater than 0.');\n }\n\n if (!(Number.isInteger(lines) && lines >= 1)) {\n throw new RangeError('ReadMore: lines must be an integer of at least 1.');\n }\n\n instances.set(element, this);\n this.#init();\n }\n\n /**\n * Instantiates {@link ReadMore} on every matching element. Elements that are\n * already initialized are left untouched and their existing instance is\n * returned, so it is safe to call again after new content is added.\n *\n * @param target - A CSS selector, an element, or any iterable or array-like\n * of elements (`NodeList`, `HTMLCollection`, array, `Set`…).\n * @param options - See {@link ReadMoreOptions}. Not applied to elements\n * that are already initialized.\n * @param root - Where to look for `target` when it is a selector.\n * @returns One instance per element, in document order.\n */\n static init(\n target: string | HTMLElement | Iterable<HTMLElement> | ArrayLike<HTMLElement>,\n options?: ReadMoreOptions,\n root: ParentNode = document\n ): ReadMore[] {\n const nodes =\n typeof target === 'string'\n ? root.querySelectorAll<HTMLElement>(target)\n : target instanceof HTMLElement ? [\n target\n ] : target;\n\n const batch: ReadMore[] = [];\n\n ReadMore.#batch = batch;\n\n try {\n return Array.from(nodes).map((n) => instances.get(n) ?? new ReadMore(n, options));\n } finally {\n ReadMore.#batch = null;\n ReadMore.#syncAll(batch);\n }\n }\n\n /** Whether the text is currently expanded. */\n get expanded(): boolean {\n return this.#expanded;\n }\n\n /**\n * Expands or collapses the text, updating the button label, the\n * `aria-expanded` attribute and the state classes, then calls\n * {@link ReadMoreOptions.onToggle} and dispatches a bubbling\n * `readmore:toggle` event on the element. Does nothing once destroyed.\n *\n * @param force - `true` to expand, `false` to collapse; the state is\n * inverted when omitted. Does nothing if the text is already in the\n * requested state.\n */\n toggle(force?: boolean): void {\n // Only a real boolean forces the state, so `toggle` still works as an\n // event listener (`addEventListener('click', rm.toggle.bind(rm))`).\n const expanded = typeof force === 'boolean' ? force : !this.#expanded;\n\n if (this.#destroyed || expanded === this.#expanded) {\n return;\n }\n\n this.#expanded = expanded;\n this.el.classList.toggle(CLAMP_CLASS, !this.#expanded);\n\n if (this.options.height != null) {\n this.el.classList.toggle(CLAMP_HEIGHT_CLASS, !this.#expanded);\n }\n\n this.el.classList.toggle(this.options.expandedClass, this.#expanded);\n this.#updateButton();\n\n const stateClass = this.#expanded ? this.options.openingClass : this.options.closingClass;\n const otherClass = this.#expanded ? this.options.closingClass : this.options.openingClass;\n\n this.el.classList.remove(otherClass);\n this.#applyTransientClass(stateClass);\n\n try {\n this.options.onToggle?.(this.#expanded, this);\n } finally {\n // The event is dispatched even if `onToggle` throws (the error still propagates).\n this.el.dispatchEvent(new CustomEvent<ReadMoreToggleDetail>('readmore:toggle', {\n bubbles: true,\n detail: {\n expanded: this.#expanded,\n instance: this\n }\n }));\n }\n }\n\n /**\n * Checks again whether the content overflows, mounting or unmounting the\n * button. Resizes of the element are already tracked; call it after\n * changing the content in a way that keeps the element's size. Does nothing\n * while expanded, during a toggle transition, or once destroyed.\n */\n refresh(): void {\n this.#sync(this.#isOverflowing());\n }\n\n /**\n * @param element - An element possibly initialized by this plugin.\n * @returns The instance bound to it, or `undefined`.\n */\n static getInstance(element: HTMLElement): ReadMore | undefined {\n return instances.get(element);\n }\n\n /**\n * Restores the element: removes the button, the classes, the CSS custom\n * properties, the listeners and the generated `id`. The element can then be\n * initialized again. Calling it again does nothing.\n */\n destroy(): void {\n if (this.#destroyed) {\n return;\n }\n\n instances.delete(this.el);\n ReadMore.#resizeObserver?.unobserve(this.el);\n\n if (this.#transientTimer !== null) {\n clearTimeout(this.#transientTimer);\n this.#transientTimer = null;\n }\n\n this.#unmountButton();\n this.el.classList.remove(\n CLAMP_CLASS,\n CLAMP_HEIGHT_CLASS,\n this.options.expandedClass,\n this.options.openingClass,\n this.options.closingClass\n );\n\n this.el.style.removeProperty('--readmore-lines');\n this.el.style.removeProperty('--readmore-height');\n\n if (this.#generatedId) {\n this.el.removeAttribute('id');\n this.#generatedId = false;\n }\n }\n\n // Measures every resized element before touching the DOM, so mounting a\n // button does not force a new layout for each following element.\n static #onResize(entries: ResizeObserverEntry[]): void {\n ReadMore.#syncAll(entries.map((entry) => instances.get(entry.target as HTMLElement)));\n }\n\n static #syncAll(list: (ReadMore | undefined)[]): void {\n const measured = new Map<ReadMore, boolean>();\n\n for (const instance of list) {\n if (instance) {\n measured.set(instance, instance.#isOverflowing());\n }\n }\n\n measured.forEach((overflowing, instance) => instance.#sync(overflowing));\n }\n\n #init(): void {\n if (this.options.height != null) {\n this.el.style.setProperty('--readmore-height', `${ this.options.height }px`);\n this.el.classList.add(CLAMP_CLASS, CLAMP_HEIGHT_CLASS);\n } else {\n this.el.style.setProperty('--readmore-lines', String(this.options.lines));\n this.el.classList.add(CLAMP_CLASS);\n }\n\n ReadMore.#resizeObserver ??= new ResizeObserver(ReadMore.#onResize);\n ReadMore.#resizeObserver.observe(this.el);\n\n // Inside init(), the batch measures all elements at once afterwards.\n if (ReadMore.#batch) {\n ReadMore.#batch.push(this);\n } else {\n this.refresh();\n }\n\n this.#refreshOnFonts();\n }\n\n // A font swap can change the overflow without resizing the element, which the\n // ResizeObserver would miss. Instances created in the same tick share one\n // measurement; destroyed ones are skipped by #sync.\n #refreshOnFonts(): void {\n if (typeof document === 'undefined' || !document.fonts) {\n return;\n }\n\n if (ReadMore.#fontQueue) {\n ReadMore.#fontQueue.push(this);\n\n return;\n }\n\n const queue: ReadMore[] = ReadMore.#fontQueue = [\n this\n ];\n\n void document.fonts.ready.then(() => {\n ReadMore.#fontQueue = null;\n ReadMore.#syncAll(queue.filter((instance) => !instance.#destroyed));\n });\n }\n\n #isOverflowing(): boolean {\n return this.el.scrollHeight - this.el.clientHeight > 1;\n }\n\n #sync(overflowing: boolean): void {\n // While a toggle transition runs, the element's size is not final: measuring\n // it mid-collapse would remove the button. The timer refreshes once it ends.\n if (this.#expanded || this.#transientTimer !== null || this.#destroyed) {\n return;\n }\n\n if (overflowing && !this.#button) {\n this.#mountButton();\n } else if (!overflowing && this.#button) {\n this.#unmountButton();\n }\n }\n\n #applyTransientClass(stateClass: string): void {\n if (this.#transientTimer !== null) {\n clearTimeout(this.#transientTimer);\n }\n\n this.el.classList.add(stateClass);\n\n // A timer rather than `transitionend`, which never fires when no declared\n // property actually changes (e.g. `transition: all` in lines mode).\n const timer = setTimeout(() => {\n // Some engines (WebKit) start the transition later than the timer assumes:\n // wait for it to end, or the size is measured mid-way and the button remounted.\n const running = this.el.getAnimations?.().filter(a => a instanceof CSSTransition) ?? [];\n const done = () => {\n // Superseded by another toggle, or destroyed meanwhile.\n if (this.#transientTimer !== timer) {\n return;\n }\n\n this.#transientTimer = null;\n this.el.classList.remove(stateClass);\n this.refresh();\n };\n\n if (running.length) {\n Promise.allSettled(running.map(a => a.finished)).then(done);\n } else {\n done();\n }\n }, this.#transitionTime());\n\n this.#transientTimer = timer;\n }\n\n /** Longest `duration + delay` among the transitions declared on the element, in ms. */\n #transitionTime(): number {\n const style = getComputedStyle(this.el);\n const durations = toMs(style.transitionDuration);\n const delays = toMs(style.transitionDelay);\n let max = 0;\n\n // Shorter lists repeat to match the longer one, as CSS does.\n for (let i = 0; i < Math.max(durations.length, delays.length); i++) {\n max = Math.max(max, durations[i % durations.length] + delays[i % delays.length]);\n }\n\n return max;\n }\n\n #mountButton(): void {\n if (!this.el.id) {\n let id: string;\n\n // Skip ids already used in the page (server-rendered markup, another copy of the plugin…).\n do {\n id = `readmore-${++idCounter}`;\n } while (document.getElementById(id));\n\n this.el.id = id;\n this.#generatedId = true;\n }\n\n const btn = document.createElement('button');\n\n btn.type = 'button';\n btn.className = this.options.buttonClass;\n btn.setAttribute('aria-controls', this.el.id);\n btn.addEventListener('click', this.#onClick);\n this.#button = btn;\n this.#updateButton();\n\n if (this.options.buttonContainer) {\n this.options.buttonContainer.appendChild(btn);\n } else {\n this.el.insertAdjacentElement('afterend', btn);\n }\n }\n\n /** Syncs the button's text, `aria-expanded` and `aria-label` with the state. */\n #updateButton(): void {\n if (!this.#button) {\n return;\n }\n\n const { moreText, lessText, moreLabel, lessLabel } = this.options;\n const label = this.#expanded ? lessLabel : moreLabel;\n\n this.#button.textContent = this.#expanded ? lessText : moreText;\n this.#button.setAttribute('aria-expanded', String(this.#expanded));\n\n if (label) {\n this.#button.setAttribute('aria-label', label);\n } else {\n this.#button.removeAttribute('aria-label');\n }\n }\n\n #unmountButton(): void {\n if (!this.#button) {\n return;\n }\n\n this.#button.removeEventListener('click', this.#onClick);\n\n // Removing the focused button would drop focus to <body>: keep it on the element.\n if (document.activeElement === this.#button) {\n const { el } = this;\n\n if (!el.hasAttribute('tabindex')) {\n el.tabIndex = -1;\n el.addEventListener('blur', () => el.removeAttribute('tabindex'), {\n once: true\n });\n }\n\n el.focus();\n }\n\n this.#button.remove();\n this.#button = null;\n }\n}\n"],"mappings":"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"}
|
|
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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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontfactory/readmore",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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": {
|
|
@@ -40,8 +40,10 @@
|
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@commitlint/cli": "^21.2.4",
|
|
42
42
|
"@commitlint/config-conventional": "^21.2.4",
|
|
43
|
+
"@fontsource/inter": "^5.3.0",
|
|
43
44
|
"@front-factory/eslint-config": "^3.0.0",
|
|
44
|
-
"@frontfactory/vitepress-theme": "^1.
|
|
45
|
+
"@frontfactory/vitepress-theme": "^1.8.0",
|
|
46
|
+
"@resvg/resvg-js": "^2.6.2",
|
|
45
47
|
"@semantic-release/changelog": "^7.0.0",
|
|
46
48
|
"@semantic-release/git": "^11.0.1",
|
|
47
49
|
"@semantic-release/github": "^12.0.10",
|
|
@@ -54,6 +56,7 @@
|
|
|
54
56
|
"jsdom": "^30.1.3",
|
|
55
57
|
"lightningcss": "^1.33.0",
|
|
56
58
|
"playwright": "^1.64.1",
|
|
59
|
+
"satori": "^0.63.2",
|
|
57
60
|
"semantic-release": "^25.0.9",
|
|
58
61
|
"typescript": "^6.0.3",
|
|
59
62
|
"typescript-eslint": "^8.72.0",
|