@frontfactory/readmore 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,10 +7,11 @@
7
7
  Lightweight, framework-agnostic plugin to clamp text to N lines (or a fixed pixel height) with an ellipsis and a
8
8
  `Read more` / `Read less` toggle button.
9
9
 
10
- - Zero dependencies, ~1 KB
10
+ - Zero dependencies, ~1.7 KB gzipped
11
11
  - TypeScript types included
12
12
  - CSS-driven clamping via `-webkit-line-clamp` or `max-height`
13
13
  - The toggle button is only created when the text actually overflows
14
+ - Accessible by default: `aria-expanded` and `aria-controls` on the toggle button
14
15
 
15
16
  ## Installation
16
17
 
@@ -18,6 +19,24 @@ Lightweight, framework-agnostic plugin to clamp text to N lines (or a fixed pixe
18
19
  npm install @frontfactory/readmore
19
20
  ```
20
21
 
22
+ Requires Node.js `^22.22.2 || >=24.15` to build from source. The published package is shipped as untranspiled ES2022
23
+ (private class members, logical assignment, `ResizeObserver`) and runs in Chrome/Edge 85+, Firefox 90+ and Safari 15+.
24
+
25
+ ### From a CDN
26
+
27
+ Without a bundler, load the stylesheet and the IIFE build, which exposes `FrontFactory.ReadMore` (an existing
28
+ `FrontFactory` global is extended, not replaced). Pin the major version in production:
29
+
30
+ ```html
31
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@frontfactory/readmore@1/dist/readmore.css">
32
+ <script src="https://cdn.jsdelivr.net/npm/@frontfactory/readmore@1/dist/readmore.iife.js"></script>
33
+ <script>
34
+ FrontFactory.ReadMore.init('.excerpt', {
35
+ lines: 3
36
+ });
37
+ </script>
38
+ ```
39
+
21
40
  ## Playground
22
41
 
23
42
  Try it live on StackBlitz — no installation required:
@@ -47,6 +66,23 @@ ReadMore.init('.excerpt', {
47
66
  });
48
67
  ```
49
68
 
69
+ `ReadMore.init()` also accepts an element, or any iterable or array-like of elements (`NodeList`, `HTMLCollection`,
70
+ array, `Set`…). A third argument restricts a selector to a container:
71
+
72
+ ```ts
73
+ ReadMore.init(document.querySelectorAll<HTMLElement>('.excerpt'), {
74
+ lines: 3
75
+ });
76
+
77
+ // only the excerpts inside #news
78
+ ReadMore.init('.excerpt', {
79
+ lines: 3
80
+ }, document.querySelector('#news')!);
81
+ ```
82
+
83
+ Calling `ReadMore.init()` again is safe: elements that are already initialized keep their instance (and its options) and
84
+ are returned as is, so you can re-run it after injecting new content.
85
+
50
86
  Or instantiate against a single element:
51
87
 
52
88
  ```ts
@@ -58,30 +94,105 @@ if (el) new ReadMore(el, {
58
94
  });
59
95
  ```
60
96
 
97
+ Each toggle also dispatches a bubbling `readmore:toggle` event on the element, handy for event delegation:
98
+
99
+ ```ts
100
+ document.addEventListener('readmore:toggle', (event) => {
101
+ const { expanded, instance } = event.detail;
102
+ });
103
+ ```
104
+
105
+ Pass `true` or `false` to `toggle()` to force a state, e.g. for an "Expand all" button. Instances already in that
106
+ state are left untouched and fire neither `onToggle` nor the event:
107
+
108
+ ```ts
109
+ const instances = ReadMore.init('.excerpt');
110
+
111
+ expandAllButton.addEventListener('click', () => instances.forEach((instance) => instance.toggle(true)));
112
+ ```
113
+
114
+ If you change the content without changing the element's size (resizes are already tracked), call `refresh()` so the
115
+ button is mounted or removed as needed:
116
+
117
+ ```ts
118
+ instance.el.textContent = newText;
119
+ instance.refresh();
120
+ ```
121
+
122
+ ### With a framework
123
+
124
+ The instance holds a button outside the element and is watched by a `ResizeObserver`, so call `destroy()` when the
125
+ component unmounts. For example with React:
126
+
127
+ ```tsx
128
+ useEffect(() => {
129
+ const instance = new ReadMore(ref.current!, {
130
+ lines: 3
131
+ });
132
+
133
+ return () => instance.destroy();
134
+ }, []);
135
+ ```
136
+
61
137
  ## Options
62
138
 
63
- | Option | Type | Default | Description |
64
- |-----------------|----------|------------------|--------------------------------------------------------------------|
65
- | `lines` | `number` | `3` | Number of lines to clamp to (min `1`). Ignored if `height` is set. |
66
- | `height` | `number` | — | Max height in pixels (min `1`). Takes precedence over `lines`. |
67
- | `moreText` | `string` | `'Read more'` | Label of the button when the text is collapsed. |
68
- | `lessText` | `string` | `'Read less'` | Label of the button when the text is expanded. |
69
- | `buttonClass` | `string` | `'readmore-btn'` | Class applied to the toggle button. |
70
- | `expandedClass` | `string` | `'is-expanded'` | Class applied to the target element while expanded. |
71
- | `openingClass` | `string` | `'is-opening'` | Transient class applied while expanding, removed after transition. |
72
- | `closingClass` | `string` | `'is-closing'` | Transient class applied while collapsing, removed after transition.|
73
- | `onToggle` | `(expanded: boolean) => void` | — | Callback fired after each toggle with the current `expanded` state. |
139
+ | Option | Type | Default | Description |
140
+ |-----------------|---------------------------------------------------|------------------|-----------------------------------------------------------------------------|
141
+ | `lines` | `number` | `3` | Number of lines to clamp to (min `1`). Ignored if `height` is set. |
142
+ | `height` | `number` | — | Max height in pixels (min `1`). Takes precedence over `lines`. |
143
+ | `moreText` | `string` | `'Read more'` | Label of the button when the text is collapsed. |
144
+ | `lessText` | `string` | `'Read less'` | Label of the button when the text is expanded. |
145
+ | `moreLabel` | `string` | — | `aria-label` of the button when collapsed. Should contain `moreText`. |
146
+ | `lessLabel` | `string` | — | `aria-label` of the button when expanded. Should contain `lessText`. |
147
+ | `buttonClass` | `string` | `'readmore-btn'` | Class applied to the toggle button. |
148
+ | `expandedClass` | `string` | `'is-expanded'` | Class applied to the target element while expanded. |
149
+ | `openingClass` | `string` | `'is-opening'` | Transient class applied while expanding, removed after transition. |
150
+ | `closingClass` | `string` | `'is-closing'` | Transient class applied while collapsing, removed after transition. |
151
+ | `onToggle` | `(expanded: boolean, instance: ReadMore) => void` | — | Callback fired after each toggle, before the `readmore:toggle` event. |
74
152
 
75
153
  ## API
76
154
 
77
155
  ```ts
78
156
  const instance = new ReadMore(element, options);
79
157
 
158
+ instance.el; // the target element
159
+ instance.options; // resolved options (read-only)
80
160
  instance.toggle(); // expand / collapse programmatically
161
+ instance.toggle(true); // force expand (false: force collapse); no-op if already in that state
81
162
  instance.expanded; // boolean getter
82
- instance.destroy(); // remove button, classes and listeners
163
+ instance.refresh(); // re-check the overflow after a content change
164
+ instance.destroy(); // remove button, classes, listeners and generated id; toggle() is a no-op afterwards
165
+
166
+ ReadMore.init(target, options, root); // target: selector | element | iterable of elements — returns ReadMore[], reusing existing instances
167
+ ReadMore.getInstance(element); // returns the ReadMore bound to an element, or undefined
83
168
 
84
- ReadMore.init(selector, options); // returns ReadMore[]
169
+ // event: 'readmore:toggle' (bubbles) — event.detail: { expanded, instance }
170
+ ```
171
+
172
+ ### Errors
173
+
174
+ The constructor throws instead of failing silently:
175
+
176
+ | Error | When |
177
+ |--------------|-------------------------------------------------------------------------------|
178
+ | `TypeError` | `element` is not an `HTMLElement`. |
179
+ | `Error` | The element is already initialized — call `destroy()` before re-initializing. |
180
+ | `RangeError` | `lines` is not an integer `>= 1`, or `height` is not a finite number `> 0`. |
181
+
182
+ ## Accessibility
183
+
184
+ When the button is mounted, it gets `aria-expanded` (kept in sync on every toggle) and `aria-controls`
185
+ pointing to the target element. If the element has no `id`, a unique one (`readmore-1`, `readmore-2`, …, skipping ids
186
+ already used in the page) is generated and removed again on `destroy()`; an existing `id` is always preserved.
187
+
188
+ When several buttons share the same visible text, give each one a distinct accessible name with `moreLabel` and
189
+ `lessLabel`. Keep the visible text inside the label so voice control users can still activate the button by its text:
190
+
191
+ ```ts
192
+ new ReadMore(el, {
193
+ moreLabel: 'Read more about Product A',
194
+ lessLabel: 'Read less about Product A'
195
+ });
85
196
  ```
86
197
 
87
198
  ## How it works
@@ -90,14 +201,18 @@ The plugin sets a CSS custom property on the target element and toggles `.readmo
90
201
  it sets `--readmore-lines` and the bundled stylesheet applies `-webkit-line-clamp`, producing an ellipsis at the end of
91
202
  the last visible line. In **height mode** (`height` option), it sets `--readmore-height` and applies `max-height` via
92
203
  the `.readmore-clamp--height` modifier class instead. The toggle button is mounted right after the element only when the
93
- content actually overflows, and unmounted again on resize if the text ends up fitting.
204
+ content actually overflows; a single `ResizeObserver`, shared by every instance, watches the elements and unmounts the
205
+ button again if the text ends up fitting (or mounts it later if it starts overflowing). All resized elements are
206
+ measured before any button is added or removed, to avoid a layout per element.
94
207
 
95
208
  ### Transient classes for animations
96
209
 
97
210
  In addition to `expandedClass` (which stays applied for the entire expanded state), the plugin applies short-lived
98
- `openingClass` and `closingClass` markers on the target element at the moment of expand and collapse respectively.
99
- Each is added when the toggle starts and removed automatically once the CSS transition on the element ends — or on the
100
- next tick if no transition is declared. Use them as hooks to drive enter/leave animations:
211
+ `openingClass` and `closingClass` markers on the target element at the moment of expand and collapse respectively. Each
212
+ is added when the toggle starts and removed automatically once the longest CSS transition declared on the element
213
+ (duration + delay) has elapsed — or on the next tick if no transition is declared. The overflow check that mounts or
214
+ unmounts the button waits for that moment too, so the button stays in place during a collapse animation. Use them as
215
+ hooks to drive enter/leave animations:
101
216
 
102
217
  ```css
103
218
  .is-opening {
@@ -108,3 +223,23 @@ next tick if no transition is declared. Use them as hooks to drive enter/leave a
108
223
  /* collapse animation */
109
224
  }
110
225
  ```
226
+
227
+ ### Fade in height mode
228
+
229
+ Add the `readmore-fade` class to the element to fade out the bottom of the clamped text in height mode (lines mode
230
+ already ends with an ellipsis). The fade is removed while expanded; its size defaults to `2em` and can be changed with
231
+ the `--readmore-fade-size` custom property:
232
+
233
+ ```html
234
+ <p class="excerpt readmore-fade" style="--readmore-fade-size: 3em">…</p>
235
+ ```
236
+
237
+ ## Limitations
238
+
239
+ - While collapsed, `.readmore-clamp` sets `display: -webkit-box` (lines mode) or `display: block` (height mode), which
240
+ overrides the element's own `display`. To clamp a flex or grid container, wrap its content in an inner element and
241
+ initialize the plugin on that element instead.
242
+ - In lines mode, how `-webkit-line-clamp` handles several block children (e.g. multiple `<p>`) varies between browsers.
243
+ For rich content, prefer height mode.
244
+ - Lines mode cannot be animated: neither `display` nor `line-clamp` can transition. For an animated expand/collapse, use
245
+ height mode with a `max-height` transition.
package/dist/readmore.css CHANGED
@@ -1 +1 @@
1
- .readmore-clamp{-webkit-line-clamp:var(--readmore-lines,3);line-clamp:var(--readmore-lines,3);-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.readmore-clamp--height{max-height:var(--readmore-height);-webkit-line-clamp:none;line-clamp:none;display:block}
1
+ .readmore-clamp{-webkit-line-clamp:var(--readmore-lines,3);line-clamp:var(--readmore-lines,3);-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.readmore-clamp--height{max-height:var(--readmore-height);-webkit-line-clamp:none;line-clamp:none;display:block}.readmore-fade.readmore-clamp--height{-webkit-mask-image:linear-gradient(to bottom, #000 calc(100% - var(--readmore-fade-size,2em)), transparent);mask-image:linear-gradient(to bottom, #000 calc(100% - var(--readmore-fade-size,2em)), transparent)}
@@ -1,27 +1,174 @@
1
+ /**
2
+ * Options accepted by {@link ReadMore}. Every field is optional.
3
+ */
1
4
  export interface ReadMoreOptions {
5
+ /**
6
+ * Number of lines to clamp to. Ignored when {@link ReadMoreOptions.height} is set.
7
+ *
8
+ * @defaultValue 3
9
+ * @throws RangeError - if not an integer greater than or equal to 1.
10
+ */
2
11
  lines?: number;
12
+ /**
13
+ * Max height in pixels. Takes precedence over {@link ReadMoreOptions.lines}
14
+ * and clamps with `max-height` instead of `-webkit-line-clamp`.
15
+ *
16
+ * @throws RangeError - if not a finite number greater than 0.
17
+ */
3
18
  height?: number;
19
+ /**
20
+ * Label of the toggle button while the text is collapsed.
21
+ *
22
+ * @defaultValue 'Read more'
23
+ */
4
24
  moreText?: string;
25
+ /**
26
+ * Label of the toggle button while the text is expanded.
27
+ *
28
+ * @defaultValue 'Read less'
29
+ */
5
30
  lessText?: string;
31
+ /**
32
+ * Accessible name (`aria-label`) of the toggle button while the text is
33
+ * collapsed, e.g. `'Read more about Product A'` when several buttons share
34
+ * the same visible text. It should contain {@link ReadMoreOptions.moreText}.
35
+ */
36
+ moreLabel?: string;
37
+ /**
38
+ * Accessible name (`aria-label`) of the toggle button while the text is
39
+ * expanded. It should contain {@link ReadMoreOptions.lessText}.
40
+ */
41
+ lessLabel?: string;
42
+ /**
43
+ * Class applied to the toggle button.
44
+ *
45
+ * @defaultValue 'readmore-btn'
46
+ */
6
47
  buttonClass?: string;
48
+ /**
49
+ * Class applied to the target element for the whole expanded state.
50
+ *
51
+ * @defaultValue 'is-expanded'
52
+ */
7
53
  expandedClass?: string;
54
+ /**
55
+ * Transient class applied when expanding, removed once the longest CSS
56
+ * transition on the element (duration + delay) has elapsed — or on the next
57
+ * tick if no transition is declared.
58
+ *
59
+ * @defaultValue 'is-opening'
60
+ */
8
61
  openingClass?: string;
62
+ /**
63
+ * Transient class applied when collapsing, removed once the longest CSS
64
+ * transition on the element (duration + delay) has elapsed — or on the next
65
+ * tick if no transition is declared.
66
+ *
67
+ * @defaultValue 'is-closing'
68
+ */
9
69
  closingClass?: string;
10
- onToggle?: (expanded: boolean) => void;
70
+ /**
71
+ * Called after each toggle with the resulting state, right before the
72
+ * `readmore:toggle` event is dispatched.
73
+ *
74
+ * @param expanded - `true` once expanded, `false` once collapsed.
75
+ * @param instance - The instance that was toggled.
76
+ */
77
+ onToggle?: (expanded: boolean, instance: ReadMore) => void;
11
78
  }
12
- type ResolvedOptions = Required<Omit<ReadMoreOptions, 'height' | 'onToggle'>> & {
13
- height?: number;
14
- onToggle?: (expanded: boolean) => void;
15
- };
79
+ type OptionalKeys = 'height' | 'moreLabel' | 'lessLabel' | 'onToggle';
80
+ /**
81
+ * {@link ReadMoreOptions} merged with the defaults, as exposed on
82
+ * {@link ReadMore.options}.
83
+ */
84
+ export type ResolvedOptions = Required<Omit<ReadMoreOptions, OptionalKeys>> & Pick<ReadMoreOptions, OptionalKeys>;
85
+ /** `detail` of the `readmore:toggle` event. */
86
+ export interface ReadMoreToggleDetail {
87
+ /** `true` once expanded, `false` once collapsed. */
88
+ expanded: boolean;
89
+ /** The instance that was toggled. */
90
+ instance: ReadMore;
91
+ }
92
+ declare global {
93
+ interface HTMLElementEventMap {
94
+ 'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;
95
+ }
96
+ interface DocumentEventMap {
97
+ 'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;
98
+ }
99
+ }
100
+ /**
101
+ * Clamps an element to a number of lines (or a fixed height) and mounts a
102
+ * `Read more` / `Read less` button right after it, but only while the content
103
+ * actually overflows.
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * import { ReadMore } from '@frontfactory/readmore';
108
+ * import '@frontfactory/readmore/style.css';
109
+ *
110
+ * ReadMore.init('.excerpt', { lines: 3 });
111
+ * ```
112
+ */
16
113
  export declare class ReadMore {
17
114
  #private;
115
+ /** The clamped element. */
18
116
  readonly el: HTMLElement;
19
- readonly options: ResolvedOptions;
117
+ /** The options this instance was built with, merged with the defaults. */
118
+ readonly options: Readonly<ResolvedOptions>;
119
+ /**
120
+ * @param element - The element to clamp.
121
+ * @param options - See {@link ReadMoreOptions}.
122
+ * @throws TypeError - if `element` is not an `HTMLElement`.
123
+ * @throws Error - if `element` is already initialized; call
124
+ * {@link ReadMore.destroy} first.
125
+ * @throws RangeError - if `lines` is not an integer greater than or equal
126
+ * to 1, or `height` is not a finite number greater than 0.
127
+ */
20
128
  constructor(element: HTMLElement, options?: ReadMoreOptions);
21
- static init(target: string | NodeListOf<HTMLElement> | HTMLElement[], options?: ReadMoreOptions): ReadMore[];
129
+ /**
130
+ * Instantiates {@link ReadMore} on every matching element. Elements that are
131
+ * already initialized are left untouched and their existing instance is
132
+ * returned, so it is safe to call again after new content is added.
133
+ *
134
+ * @param target - A CSS selector, an element, or any iterable or array-like
135
+ * of elements (`NodeList`, `HTMLCollection`, array, `Set`…).
136
+ * @param options - See {@link ReadMoreOptions}. Not applied to elements
137
+ * that are already initialized.
138
+ * @param root - Where to look for `target` when it is a selector.
139
+ * @returns One instance per element, in document order.
140
+ */
141
+ static init(target: string | HTMLElement | Iterable<HTMLElement> | ArrayLike<HTMLElement>, options?: ReadMoreOptions, root?: ParentNode): ReadMore[];
142
+ /** Whether the text is currently expanded. */
22
143
  get expanded(): boolean;
23
- toggle(): void;
144
+ /**
145
+ * Expands or collapses the text, updating the button label, the
146
+ * `aria-expanded` attribute and the state classes, then calls
147
+ * {@link ReadMoreOptions.onToggle} and dispatches a bubbling
148
+ * `readmore:toggle` event on the element. Does nothing once destroyed.
149
+ *
150
+ * @param force - `true` to expand, `false` to collapse; the state is
151
+ * inverted when omitted. Does nothing if the text is already in the
152
+ * requested state.
153
+ */
154
+ toggle(force?: boolean): void;
155
+ /**
156
+ * Checks again whether the content overflows, mounting or unmounting the
157
+ * button. Resizes of the element are already tracked; call it after
158
+ * changing the content in a way that keeps the element's size. Does nothing
159
+ * while expanded, during a toggle transition, or once destroyed.
160
+ */
161
+ refresh(): void;
162
+ /**
163
+ * @param element - An element possibly initialized by this plugin.
164
+ * @returns The instance bound to it, or `undefined`.
165
+ */
24
166
  static getInstance(element: HTMLElement): ReadMore | undefined;
167
+ /**
168
+ * Restores the element: removes the button, the classes, the CSS custom
169
+ * properties, the listeners and the generated `id`. The element can then be
170
+ * initialized again. Calling it again does nothing.
171
+ */
25
172
  destroy(): void;
26
173
  }
27
174
  export {};
@@ -0,0 +1 @@
1
+ (function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var t=`readmore-clamp`,n=`readmore-clamp--height`,r=new WeakMap,i=0,a={lines:3,moreText:`Read more`,lessText:`Read less`,buttonClass:`readmore-btn`,expandedClass:`is-expanded`,openingClass:`is-opening`,closingClass:`is-closing`};function o(e){return e.split(`,`).map(e=>parseFloat(e)*1e3||0)}e.ReadMore=class e{static#e;el;options;#t=null;#n=!1;#r=!1;#i=null;#a=()=>this.toggle();get#o(){return r.get(this.el)!==this}constructor(e,t={}){if(!(e instanceof HTMLElement))throw TypeError(`ReadMore: an HTMLElement is required.`);if(r.has(e))throw Error(`ReadMore: element is already initialized. Call destroy() first.`);this.el=e,this.options={...a,...Object.fromEntries(Object.entries(t).filter(e=>e[1]!==void 0))};let{height:n,lines:i}=this.options;if(n!=null&&!(Number.isFinite(n)&&n>0))throw RangeError(`ReadMore: height must be a finite number greater than 0.`);if(!(Number.isInteger(i)&&i>=1))throw RangeError(`ReadMore: lines must be an integer of at least 1.`);r.set(e,this),this.#c()}static init(t,n,i=document){let a=typeof t==`string`?i.querySelectorAll(t):t instanceof HTMLElement?[t]:t;return Array.from(a).map(t=>r.get(t)??new e(t,n))}get expanded(){return this.#n}toggle(e){let r=typeof e==`boolean`?e:!this.#n;if(this.#o||r===this.#n)return;this.#n=r,this.el.classList.toggle(t,!this.#n),this.options.height!=null&&this.el.classList.toggle(n,!this.#n),this.el.classList.toggle(this.options.expandedClass,this.#n),this.#m();let i=this.#n?this.options.openingClass:this.options.closingClass,a=this.#n?this.options.closingClass:this.options.openingClass;this.el.classList.remove(a),this.#d(i),this.options.onToggle?.(this.#n,this),this.el.dispatchEvent(new CustomEvent(`readmore:toggle`,{bubbles:!0,detail:{expanded:this.#n,instance:this}}))}refresh(){this.#u(this.#l())}static getInstance(e){return r.get(e)}destroy(){this.#o||(r.delete(this.el),e.#e?.unobserve(this.el),this.#i!==null&&(clearTimeout(this.#i),this.#i=null),this.#h(),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.#r&&=(this.el.removeAttribute(`id`),!1))}static#s(e){let t=new Map;for(let n of e){let e=r.get(n.target);e&&t.set(e,e.#l())}t.forEach((e,t)=>t.#u(e))}#c(){this.options.height==null?(this.el.style.setProperty(`--readmore-lines`,String(this.options.lines)),this.el.classList.add(t)):(this.el.style.setProperty(`--readmore-height`,`${this.options.height}px`),this.el.classList.add(t,n)),e.#e??=new ResizeObserver(e.#s),e.#e.observe(this.el),this.refresh()}#l(){return this.el.scrollHeight-this.el.clientHeight>1}#u(e){this.#n||this.#i!==null||this.#o||(e&&!this.#t?this.#p():!e&&this.#t&&this.#h())}#d(e){this.#i!==null&&clearTimeout(this.#i),this.el.classList.add(e),this.#i=setTimeout(()=>{this.#i=null,this.el.classList.remove(e),this.refresh()},this.#f())}#f(){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}#p(){if(!this.el.id){let e;do e=`readmore-${++i}`;while(document.getElementById(e));this.el.id=e,this.#r=!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.#a),this.#t=e,this.#m(),this.el.insertAdjacentElement(`afterend`,e)}#m(){if(!this.#t)return;let{moreText:e,lessText:t,moreLabel:n,lessLabel:r}=this.options,i=this.#n?r:n;this.#t.textContent=this.#n?t:e,this.#t.setAttribute(`aria-expanded`,String(this.#n)),i?this.#t.setAttribute(`aria-label`,i):this.#t.removeAttribute(`aria-label`)}#h(){this.#t&&=(this.#t.removeEventListener(`click`,this.#a),this.#t.remove(),null)}}})(this.FrontFactory=this.FrontFactory||{});
package/dist/readmore.js CHANGED
@@ -7,74 +7,109 @@ var e = "readmore-clamp", t = "readmore-clamp--height", n = /* @__PURE__ */ new
7
7
  expandedClass: "is-expanded",
8
8
  openingClass: "is-opening",
9
9
  closingClass: "is-closing"
10
- }, a = class a {
10
+ };
11
+ function a(e) {
12
+ return e.split(",").map((e) => parseFloat(e) * 1e3 || 0);
13
+ }
14
+ var o = class o {
15
+ static #e;
11
16
  el;
12
17
  options;
13
- #e = null;
14
- #t = !1;
18
+ #t = null;
15
19
  #n = !1;
16
- #r;
20
+ #r = !1;
17
21
  #i = null;
18
22
  #a = () => this.toggle();
23
+ get #o() {
24
+ return n.get(this.el) !== this;
25
+ }
19
26
  constructor(e, t = {}) {
20
27
  if (!(e instanceof HTMLElement)) throw TypeError("ReadMore: an HTMLElement is required.");
21
28
  if (n.has(e)) throw Error("ReadMore: element is already initialized. Call destroy() first.");
22
- if (this.el = e, this.options = {
29
+ this.el = e, this.options = {
23
30
  ...i,
24
- ...t
25
- }, this.options.height != null && this.options.height <= 0) throw RangeError("ReadMore: height must be greater than 0.");
26
- if (this.options.lines < 1) throw RangeError("ReadMore: lines must be at least 1.");
27
- this.#r = new ResizeObserver(() => this.#c()), n.set(e, this), this.#o();
31
+ ...Object.fromEntries(Object.entries(t).filter((e) => e[1] !== void 0))
32
+ };
33
+ let { height: r, lines: a } = this.options;
34
+ if (r != null && !(Number.isFinite(r) && r > 0)) throw RangeError("ReadMore: height must be a finite number greater than 0.");
35
+ if (!(Number.isInteger(a) && a >= 1)) throw RangeError("ReadMore: lines must be an integer of at least 1.");
36
+ n.set(e, this), this.#c();
28
37
  }
29
- static init(e, t) {
30
- let n = typeof e == "string" ? document.querySelectorAll(e) : e;
31
- return Array.from(n).map((e) => new a(e, t));
38
+ static init(e, t, r = document) {
39
+ let i = typeof e == "string" ? r.querySelectorAll(e) : e instanceof HTMLElement ? [e] : e;
40
+ return Array.from(i).map((e) => n.get(e) ?? new o(e, t));
32
41
  }
33
42
  get expanded() {
34
- return this.#t;
43
+ return this.#n;
44
+ }
45
+ toggle(n) {
46
+ let r = typeof n == "boolean" ? n : !this.#n;
47
+ if (this.#o || r === this.#n) return;
48
+ this.#n = r, this.el.classList.toggle(e, !this.#n), this.options.height != null && this.el.classList.toggle(t, !this.#n), this.el.classList.toggle(this.options.expandedClass, this.#n), this.#m();
49
+ let i = this.#n ? this.options.openingClass : this.options.closingClass, a = this.#n ? this.options.closingClass : this.options.openingClass;
50
+ this.el.classList.remove(a), this.#d(i), this.options.onToggle?.(this.#n, this), this.el.dispatchEvent(new CustomEvent("readmore:toggle", {
51
+ bubbles: !0,
52
+ detail: {
53
+ expanded: this.#n,
54
+ instance: this
55
+ }
56
+ }));
35
57
  }
36
- toggle() {
37
- this.#t = !this.#t, this.el.classList.toggle(e, !this.#t), this.options.height != null && this.el.classList.toggle(t, !this.#t), this.el.classList.toggle(this.options.expandedClass, this.#t), this.#e && (this.#e.textContent = this.#t ? this.options.lessText : this.options.moreText, this.#e.setAttribute("aria-expanded", String(this.#t)));
38
- let n = this.#t ? this.options.openingClass : this.options.closingClass, r = this.#t ? this.options.closingClass : this.options.openingClass;
39
- this.el.classList.remove(r), this.#l(n), this.#t || this.#c(), this.options.onToggle?.(this.#t);
58
+ refresh() {
59
+ this.#u(this.#l());
40
60
  }
41
61
  static getInstance(e) {
42
62
  return n.get(e);
43
63
  }
44
64
  destroy() {
45
- n.delete(this.el), this.#r.disconnect(), this.#i &&= (this.el.removeEventListener("transitionend", this.#i), null), this.#d(), this.el.classList.remove(e, t, this.options.expandedClass, this.options.openingClass, this.options.closingClass), this.el.style.removeProperty("--readmore-lines"), this.el.style.removeProperty("--readmore-height"), this.#n &&= (this.el.removeAttribute("id"), !1);
65
+ this.#o || (n.delete(this.el), o.#e?.unobserve(this.el), this.#i !== null && (clearTimeout(this.#i), this.#i = null), this.#h(), this.el.classList.remove(e, t, this.options.expandedClass, this.options.openingClass, this.options.closingClass), this.el.style.removeProperty("--readmore-lines"), this.el.style.removeProperty("--readmore-height"), this.#r &&= (this.el.removeAttribute("id"), !1));
46
66
  }
47
- #o() {
48
- this.options.height == null ? (this.el.style.setProperty("--readmore-lines", String(this.options.lines)), this.el.classList.add(e)) : (this.el.style.setProperty("--readmore-height", `${this.options.height}px`), this.el.classList.add(e, t)), this.#r.observe(this.el), this.#c();
67
+ static #s(e) {
68
+ let t = /* @__PURE__ */ new Map();
69
+ for (let r of e) {
70
+ let e = n.get(r.target);
71
+ e && t.set(e, e.#l());
72
+ }
73
+ t.forEach((e, t) => t.#u(e));
74
+ }
75
+ #c() {
76
+ this.options.height == null ? (this.el.style.setProperty("--readmore-lines", String(this.options.lines)), this.el.classList.add(e)) : (this.el.style.setProperty("--readmore-height", `${this.options.height}px`), this.el.classList.add(e, t)), o.#e ??= new ResizeObserver(o.#s), o.#e.observe(this.el), this.refresh();
49
77
  }
50
- #s() {
78
+ #l() {
51
79
  return this.el.scrollHeight - this.el.clientHeight > 1;
52
80
  }
53
- #c() {
54
- if (this.#t) return;
55
- let e = this.#s();
56
- e && !this.#e ? this.#u() : !e && this.#e && this.#d();
57
- }
58
- #l(e) {
59
- this.#i &&= (this.el.removeEventListener("transitionend", this.#i), null), this.el.classList.add(e);
60
- let t = () => this.el.classList.remove(e);
61
- if (!getComputedStyle(this.el).transitionDuration.split(",").some((e) => parseFloat(e) > 0)) {
62
- setTimeout(t, 0);
63
- return;
64
- }
65
- let n = (e) => {
66
- e.target === this.el && (this.el.removeEventListener("transitionend", n), this.#i = null, t());
67
- };
68
- this.#i = n, this.el.addEventListener("transitionend", n);
81
+ #u(e) {
82
+ this.#n || this.#i !== null || this.#o || (e && !this.#t ? this.#p() : !e && this.#t && this.#h());
83
+ }
84
+ #d(e) {
85
+ this.#i !== null && clearTimeout(this.#i), this.el.classList.add(e), this.#i = setTimeout(() => {
86
+ this.#i = null, this.el.classList.remove(e), this.refresh();
87
+ }, this.#f());
69
88
  }
70
- #u() {
71
- this.el.id || (this.el.id = `readmore-${++r}`, this.#n = !0);
89
+ #f() {
90
+ let e = getComputedStyle(this.el), t = a(e.transitionDuration), n = a(e.transitionDelay), r = 0;
91
+ for (let e = 0; e < Math.max(t.length, n.length); e++) r = Math.max(r, t[e % t.length] + n[e % n.length]);
92
+ return r;
93
+ }
94
+ #p() {
95
+ if (!this.el.id) {
96
+ let e;
97
+ do
98
+ e = `readmore-${++r}`;
99
+ while (document.getElementById(e));
100
+ this.el.id = e, this.#r = !0;
101
+ }
72
102
  let e = document.createElement("button");
73
- e.type = "button", e.className = this.options.buttonClass, e.textContent = this.options.moreText, e.setAttribute("aria-expanded", "false"), e.setAttribute("aria-controls", this.el.id), e.addEventListener("click", this.#a), this.el.insertAdjacentElement("afterend", e), this.#e = e;
103
+ e.type = "button", e.className = this.options.buttonClass, e.setAttribute("aria-controls", this.el.id), e.addEventListener("click", this.#a), this.#t = e, this.#m(), this.el.insertAdjacentElement("afterend", e);
104
+ }
105
+ #m() {
106
+ if (!this.#t) return;
107
+ let { moreText: e, lessText: t, moreLabel: n, lessLabel: r } = this.options, i = this.#n ? r : n;
108
+ this.#t.textContent = this.#n ? t : e, this.#t.setAttribute("aria-expanded", String(this.#n)), i ? this.#t.setAttribute("aria-label", i) : this.#t.removeAttribute("aria-label");
74
109
  }
75
- #d() {
76
- this.#e &&= (this.#e.removeEventListener("click", this.#a), this.#e.remove(), null);
110
+ #h() {
111
+ this.#t &&= (this.#t.removeEventListener("click", this.#a), this.#t.remove(), null);
77
112
  }
78
113
  };
79
114
  //#endregion
80
- export { a as ReadMore };
115
+ export { o as ReadMore };
package/package.json CHANGED
@@ -1,64 +1,78 @@
1
1
  {
2
2
  "name": "@frontfactory/readmore",
3
- "version": "1.3.0",
4
- "description": "Lightweight plugin to clamp text to N lines with ellipsis and a read more action.",
3
+ "version": "1.4.0",
4
+ "description": "Zero-dependency plugin to truncate text to N lines or a fixed height, with an ellipsis and an accessible read more / read less toggle.",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
8
+ "lint": "eslint .",
8
9
  "lint:js": "eslint --fix .",
9
10
  "build": "vite build",
10
11
  "preview": "vite preview",
11
12
  "typecheck": "tsc --noEmit",
12
- "test": "vitest run",
13
+ "test": "vitest run --project unit",
14
+ "test:browser": "vitest run --project browser --outputFile.junit=junit-browser.xml",
13
15
  "test:watch": "vitest",
14
- "test:coverage": "vitest run --coverage",
15
- "prepare": "husky && npm run build",
16
+ "test:coverage": "vitest run --project unit --coverage",
17
+ "prepare": "husky",
18
+ "prepack": "npm run build",
16
19
  "release": "semantic-release"
17
20
  },
18
21
  "engines": {
19
- "node": ">=20.19"
22
+ "node": "^22.22.2 || >=24.15"
20
23
  },
21
24
  "main": "./dist/readmore.js",
22
25
  "types": "./dist/readmore.d.ts",
26
+ "unpkg": "./dist/readmore.iife.js",
27
+ "jsdelivr": "./dist/readmore.iife.js",
23
28
  "exports": {
24
29
  ".": {
25
30
  "types": "./dist/readmore.d.ts",
26
- "import": "./dist/readmore.js"
31
+ "import": "./dist/readmore.js",
32
+ "default": "./dist/readmore.js"
27
33
  },
28
34
  "./style.css": "./dist/readmore.css",
29
35
  "./package.json": "./package.json"
30
36
  },
31
37
  "devDependencies": {
32
- "@commitlint/cli": "^20.5.3",
33
- "@commitlint/config-conventional": "^20.5.3",
38
+ "@commitlint/cli": "^21.2.2",
39
+ "@commitlint/config-conventional": "^21.2.2",
34
40
  "@front-factory/eslint-config": "^2.4.2",
35
- "@semantic-release/changelog": "^6.0.3",
36
- "@semantic-release/git": "^10.0.1",
37
- "@semantic-release/github": "^12.0.6",
38
- "@vitest/coverage-v8": "^4.1.5",
41
+ "@semantic-release/changelog": "^7.0.0",
42
+ "@semantic-release/git": "^11.0.1",
43
+ "@semantic-release/github": "^12.0.9",
44
+ "@vitest/browser-playwright": "^5.0.0",
45
+ "@vitest/coverage-v8": "^5.0.0",
39
46
  "conventional-changelog-conventionalcommits": "^9.3.1",
40
- "eslint": "^10.3.0",
47
+ "eslint": "^10.10.0",
41
48
  "husky": "^9.1.7",
42
- "jsdom": "^29.1.1",
43
- "lightningcss": "^1.32.0",
44
- "semantic-release": "^25.0.3",
45
- "typescript": "^5.4.0",
46
- "typescript-eslint": "^8.59.2",
47
- "vite": "^8.0.11",
48
- "vite-plugin-dts": "^5.0.0",
49
- "vite-plugin-static-copy": "^4.1.0",
50
- "vitest": "^4.1.5"
49
+ "jsdom": "^30.0.1",
50
+ "lightningcss": "^1.33.0",
51
+ "playwright": "^1.63.0",
52
+ "semantic-release": "^25.0.9",
53
+ "typescript": "^6.0.3",
54
+ "typescript-eslint": "^8.70.0",
55
+ "vite": "^8.3.0",
56
+ "vite-plugin-dts": "^5.1.0",
57
+ "vite-plugin-static-copy": "^4.1.1",
58
+ "vitest": "^5.0.0"
51
59
  },
52
- "sideEffects": false,
53
- "files": [
54
- "dist"
55
- ],
56
60
  "keywords": [
57
61
  "readmore",
62
+ "read-more",
63
+ "show-more",
64
+ "truncate",
65
+ "text-truncate",
66
+ "line-clamp",
67
+ "clamp",
68
+ "ellipsis",
58
69
  "expand",
59
70
  "collapse",
60
- "front-end"
71
+ "typescript",
72
+ "vanilla-js",
73
+ "a11y"
61
74
  ],
75
+ "author": "Front Factory",
62
76
  "license": "MIT",
63
77
  "homepage": "https://github.com/front-factory/readmore",
64
78
  "bugs": {
@@ -68,6 +82,12 @@
68
82
  "type": "git",
69
83
  "url": "git+https://github.com/front-factory/readmore.git"
70
84
  },
85
+ "sideEffects": [
86
+ "**/*.css"
87
+ ],
88
+ "files": [
89
+ "dist"
90
+ ],
71
91
  "publishConfig": {
72
92
  "access": "public",
73
93
  "provenance": true