@unlk/keymaster 1.4.5 → 1.4.7

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.
@@ -0,0 +1,43 @@
1
+ import BaseComponent from 'bootstrap/js/src/base-component.js';
2
+ import EventHandler from 'bootstrap/js/src/dom/event-handler.js';
3
+ import SelectorEngine from 'bootstrap/js/src/dom/selector-engine.js';
4
+ import { defineJQueryPlugin } from 'bootstrap/js/src/util/index.js';
5
+
6
+ const NAME = 'accordionScroll';
7
+ const EVENT_SHOWN = 'shown.bs.collapse';
8
+ const SELECTOR_ACCORDION = '.accordion';
9
+ const SELECTOR_ITEM = '.accordion-item';
10
+ const SELECTOR_HEADER = '.accordion-header';
11
+
12
+ class AccordionScroll extends BaseComponent {
13
+ _onShown(e) {
14
+ const item = e.target.closest(SELECTOR_ITEM);
15
+ if (!item) return;
16
+
17
+ const header = SelectorEngine.findOne(SELECTOR_HEADER, item);
18
+ if (!header) return;
19
+
20
+ const rect = header.getBoundingClientRect();
21
+ if (rect.top < 0) {
22
+ header.scrollIntoView({ behavior: 'smooth', block: 'start' });
23
+ }
24
+ }
25
+
26
+ static get NAME() {
27
+ return NAME;
28
+ }
29
+
30
+ static jQueryInterface(config) {
31
+ return this.each(function () {
32
+ AccordionScroll.getOrCreateInstance(this, config);
33
+ });
34
+ }
35
+ }
36
+
37
+ EventHandler.on(document, EVENT_SHOWN, SELECTOR_ACCORDION, function (e) {
38
+ AccordionScroll.getOrCreateInstance(this)._onShown(e);
39
+ });
40
+
41
+ defineJQueryPlugin(AccordionScroll);
42
+
43
+ export default AccordionScroll;
package/js/bootstrap.js CHANGED
@@ -14,3 +14,4 @@ export { default as Datepicker } from './datepicker';
14
14
  export { default as CarouselCaption } from './carousel-caption';
15
15
  export { default as CarouselHeight } from './carousel-height';
16
16
  export { default as VideoModal } from './video-modal';
17
+ export { default as AccordionScroll } from './accordion-scroll';
package/js/video-modal.js CHANGED
@@ -84,6 +84,13 @@ class VideoModal extends BaseComponent {
84
84
  this._element.dataset.vimeoId = String(vimeoId);
85
85
  this._element.__unlkVimeoPlayer = this._vimeoPlayer;
86
86
 
87
+ this._element.dispatchEvent(
88
+ new CustomEvent('videomodal.ready', {
89
+ bubbles: true,
90
+ detail: { provider: 'vimeo', player: this._vimeoPlayer, videoId: vimeoId }
91
+ })
92
+ );
93
+
87
94
  return;
88
95
  }
89
96
 
@@ -99,6 +106,13 @@ class VideoModal extends BaseComponent {
99
106
 
100
107
  this._element.dataset.ytId = youtubeId;
101
108
  this._element.__unlkYtPlayer = this._youtubePlayer;
109
+
110
+ this._element.dispatchEvent(
111
+ new CustomEvent('videomodal.ready', {
112
+ bubbles: true,
113
+ detail: { provider: 'youtube', player: this._youtubePlayer, videoId: youtubeId }
114
+ })
115
+ );
102
116
  }
103
117
  }
104
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unlk/keymaster",
3
- "version": "1.4.5",
3
+ "version": "1.4.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -28,10 +28,15 @@
28
28
  "dist-clean": "node build/dist-clean.js",
29
29
  "lint": "npm-run-all --aggregate-output --continue-on-error --parallel js-lint css-lint"
30
30
  },
31
+ "exports": {
32
+ "./*": "./*",
33
+ "./react": "./react/index.js"
34
+ },
31
35
  "files": [
32
36
  "dist",
33
37
  "scss",
34
38
  "js",
39
+ "react",
35
40
  "fonts",
36
41
  "README.md",
37
42
  "CHANGELOG.md"
@@ -51,7 +56,13 @@
51
56
  "homepage": "https://keymaster.unlock.com",
52
57
  "peerDependencies": {
53
58
  "@popperjs/core": "^2.11.0",
54
- "bootstrap": "^5.3.0"
59
+ "bootstrap": "^5.3.0",
60
+ "react": ">=18"
61
+ },
62
+ "peerDependenciesMeta": {
63
+ "react": {
64
+ "optional": true
65
+ }
55
66
  },
56
67
  "devDependencies": {
57
68
  "@babel/core": "^7.27.1",
package/react/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { useDatepicker } from './use-datepicker.js';
2
+ export { useCarouselCaption } from './use-carousel-caption.js';
3
+ export { useCarouselHeight } from './use-carousel-height.js';
4
+ export { useVideoModal } from './use-video-modal.js';
5
+ export { useAccordionScroll } from './use-accordion-scroll.js';
@@ -0,0 +1,36 @@
1
+ import { useCallback } from 'react';
2
+
3
+ /**
4
+ * Replicates accordion-scroll.js behaviour in React.
5
+ *
6
+ * After an accordion panel finishes opening, scrolls its header into view
7
+ * if it has been pushed above the viewport, replacing the Bootstrap
8
+ * shown.bs.collapse event listener.
9
+ *
10
+ * Usage:
11
+ * const { onEntered } = useAccordionScroll();
12
+ * <Accordion>
13
+ * <Accordion.Item eventKey="0">
14
+ * <Accordion.Header>Header</Accordion.Header>
15
+ * <Accordion.Collapse eventKey="0" onEntered={onEntered}>
16
+ * <Accordion.Body>Content</Accordion.Body>
17
+ * </Accordion.Collapse>
18
+ * </Accordion.Item>
19
+ * </Accordion>
20
+ */
21
+ export function useAccordionScroll() {
22
+ const onEntered = useCallback((node) => {
23
+ const item = node.closest('.accordion-item');
24
+ if (!item) return;
25
+
26
+ const header = item.querySelector('.accordion-header');
27
+ if (!header) return;
28
+
29
+ const rect = header.getBoundingClientRect();
30
+ if (rect.top < 0) {
31
+ header.scrollIntoView({ behavior: 'smooth', block: 'start' });
32
+ }
33
+ }, []);
34
+
35
+ return { onEntered };
36
+ }
@@ -0,0 +1,24 @@
1
+ import { useState, useCallback } from 'react';
2
+
3
+ /**
4
+ * Replicates carousel-caption.js behaviour in React.
5
+ *
6
+ * Tracks the active slide index and returns the caption for that slide,
7
+ * replacing the Bootstrap slid.bs.carousel event listener.
8
+ *
9
+ * @param {Array<{ caption: string }>} slides - Array of slide data objects
10
+ *
11
+ * Usage:
12
+ * const { activeIndex, onSelect, caption } = useCarouselCaption(slides);
13
+ * <Carousel activeIndex={activeIndex} onSelect={onSelect}>...</Carousel>
14
+ * <div className="carousel-caption-container"><p>{caption}</p></div>
15
+ */
16
+ export function useCarouselCaption(slides) {
17
+ const [activeIndex, setActiveIndex] = useState(0);
18
+
19
+ const onSelect = useCallback((index) => setActiveIndex(index), []);
20
+
21
+ const caption = slides[activeIndex]?.caption ?? '';
22
+
23
+ return { activeIndex, onSelect, caption };
24
+ }
@@ -0,0 +1,39 @@
1
+ import { useRef, useCallback } from 'react';
2
+
3
+ /**
4
+ * Replicates carousel-height.js behaviour in React.
5
+ *
6
+ * Animates .carousel-inner height between slides of different heights,
7
+ * replacing the Bootstrap slide.bs.carousel / slid.bs.carousel listeners.
8
+ *
9
+ * Attach `ref` to the wrapping element or the <Carousel> element,
10
+ * and pass onSlide / onSlid to the <Carousel> component.
11
+ *
12
+ * Usage:
13
+ * const { ref, onSlide, onSlid } = useCarouselHeight();
14
+ * <Carousel ref={ref} onSlide={onSlide} onSlid={onSlid}>...</Carousel>
15
+ */
16
+ export function useCarouselHeight() {
17
+ const ref = useRef(null);
18
+
19
+ const onSlide = useCallback(() => {
20
+ const el = ref.current;
21
+ if (!el) return;
22
+ const inner = el.querySelector('.carousel-inner');
23
+ const next = el.querySelector('.carousel-item-next, .carousel-item-prev');
24
+ if (!inner || !next) return;
25
+ inner.style.height = `${inner.offsetHeight}px`;
26
+ requestAnimationFrame(() => {
27
+ inner.style.height = `${next.offsetHeight}px`;
28
+ });
29
+ }, []);
30
+
31
+ const onSlid = useCallback(() => {
32
+ const el = ref.current;
33
+ if (!el) return;
34
+ const inner = el.querySelector('.carousel-inner');
35
+ if (inner) inner.style.height = '';
36
+ }, []);
37
+
38
+ return { ref, onSlide, onSlid };
39
+ }
@@ -0,0 +1,33 @@
1
+ import { useState, useCallback } from 'react';
2
+
3
+ /**
4
+ * Replicates datepicker.js behaviour in React.
5
+ *
6
+ * Renders as type="text" (so the floating label placeholder shows),
7
+ * switches to type="date" on focus (opens the native date picker),
8
+ * and reverts on blur if no value is selected.
9
+ *
10
+ * Usage:
11
+ * const dp = useDatepicker();
12
+ * <input type={dp.type} className={dp.hasValue ? 'has-value' : ''}
13
+ * onFocus={dp.onFocus} onBlur={dp.onBlur} onChange={dp.onChange} />
14
+ */
15
+ export function useDatepicker() {
16
+ const [type, setType] = useState('text');
17
+ const [hasValue, setHasValue] = useState(false);
18
+
19
+ const onFocus = useCallback(() => setType('date'), []);
20
+
21
+ const onBlur = useCallback((e) => {
22
+ if (!e.target.value) {
23
+ setType('text');
24
+ setHasValue(false);
25
+ }
26
+ }, []);
27
+
28
+ const onChange = useCallback((e) => {
29
+ setHasValue(Boolean(e.target.value));
30
+ }, []);
31
+
32
+ return { type, hasValue, onFocus, onBlur, onChange };
33
+ }
@@ -0,0 +1,143 @@
1
+ import { useRef, useCallback } from 'react';
2
+
3
+ // Module-level promises so the API scripts are only ever loaded once
4
+ let ytAPIPromise = null;
5
+ let vimeoAPIPromise = null;
6
+
7
+ function loadYouTubeAPI() {
8
+ if (ytAPIPromise) return ytAPIPromise;
9
+
10
+ ytAPIPromise = new Promise((resolve) => {
11
+ if (globalThis.YT?.Player) {
12
+ resolve();
13
+
14
+ return;
15
+ }
16
+
17
+ const prev = globalThis.onYouTubeIframeAPIReady;
18
+
19
+ globalThis.onYouTubeIframeAPIReady = () => {
20
+ try {
21
+ if (typeof prev === 'function') prev();
22
+ } catch {}
23
+
24
+ resolve();
25
+ };
26
+
27
+ const s = document.createElement('script');
28
+
29
+ s.src = 'https://www.youtube.com/iframe_api';
30
+ document.head.append(s);
31
+ });
32
+
33
+ return ytAPIPromise;
34
+ }
35
+
36
+ function loadVimeoAPI() {
37
+ if (vimeoAPIPromise) return vimeoAPIPromise;
38
+
39
+ vimeoAPIPromise = new Promise((resolve) => {
40
+ if (globalThis.Vimeo?.Player) {
41
+ resolve();
42
+
43
+ return;
44
+ }
45
+
46
+ const s = document.createElement('script');
47
+
48
+ s.src = 'https://player.vimeo.com/api/player.js';
49
+ s.addEventListener('load', resolve);
50
+ document.head.append(s);
51
+ });
52
+
53
+ return vimeoAPIPromise;
54
+ }
55
+
56
+ function getYouTubeId(url) {
57
+ const m = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|v\/)|youtu\.be\/)([\w-]{11})/);
58
+
59
+ return m?.[1] ?? '';
60
+ }
61
+
62
+ function getVimeoId(url) {
63
+ const m = url.match(/vimeo\.com\/(\d+)/);
64
+
65
+ return m?.[1] ?? '';
66
+ }
67
+
68
+ /**
69
+ * Replicates video-modal.js behaviour in React.
70
+ *
71
+ * Auto-initialises a YouTube or Vimeo player when the modal opens and
72
+ * destroys it when the modal closes, replacing the Bootstrap
73
+ * show.bs.modal / hidden.bs.modal event listeners.
74
+ *
75
+ * @param {object} options
76
+ * @param {string} [options.youtubeSrc] - YouTube video URL
77
+ * @param {string} [options.vimeoUrl] - Vimeo video URL
78
+ * @param {function} [options.onReady] - Called with { provider, player, videoId } when the player is ready
79
+ *
80
+ * Usage:
81
+ * const { containerRef, onShow, onHide } = useVideoModal({ youtubeSrc: '...' });
82
+ * <Modal onShow={onShow} onHide={onHide}>
83
+ * <div className="ratio ratio-16x9" ref={containerRef}>
84
+ * <div data-yt-player /> ← YouTube mounts here
85
+ * </div>
86
+ * </Modal>
87
+ */
88
+ export function useVideoModal({ youtubeSrc, vimeoUrl, onReady } = {}) {
89
+ const containerRef = useRef(null);
90
+ const players = useRef({ youtube: null, vimeo: null });
91
+
92
+ const onShow = useCallback(async () => {
93
+ const container = containerRef.current;
94
+
95
+ if (!container) return;
96
+
97
+ if (vimeoUrl) {
98
+ await loadVimeoAPI();
99
+ const vimeoId = getVimeoId(vimeoUrl);
100
+
101
+ players.current.vimeo = new globalThis.Vimeo.Player(container, {
102
+ id: vimeoId,
103
+ autoplay: true,
104
+ });
105
+
106
+ if (onReady) {
107
+ onReady({ provider: 'vimeo', player: players.current.vimeo, videoId: vimeoId });
108
+ }
109
+
110
+ return;
111
+ }
112
+
113
+ if (youtubeSrc) {
114
+ await loadYouTubeAPI();
115
+ const youtubeId = getYouTubeId(youtubeSrc);
116
+
117
+ const playerDiv = container.querySelector('[data-yt-player]');
118
+
119
+ players.current.youtube = new globalThis.YT.Player(playerDiv, {
120
+ videoId: youtubeId,
121
+ playerVars: { autoplay: 1, rel: 0 },
122
+ });
123
+
124
+ if (onReady) {
125
+ onReady({ provider: 'youtube', player: players.current.youtube, videoId: youtubeId });
126
+ }
127
+ }
128
+ }, [youtubeSrc, vimeoUrl, onReady]);
129
+
130
+ const onHide = useCallback(() => {
131
+ if (players.current.vimeo) {
132
+ players.current.vimeo.unload().catch(() => {});
133
+ players.current.vimeo = null;
134
+ }
135
+
136
+ if (players.current.youtube) {
137
+ players.current.youtube.destroy();
138
+ players.current.youtube = null;
139
+ }
140
+ }, []);
141
+
142
+ return { containerRef, onShow, onHide };
143
+ }
@@ -26,19 +26,19 @@
26
26
  @extend .mt-3;
27
27
  display: flex;
28
28
  justify-content: end;
29
- }
30
- .carousel-control-prev,
31
- .carousel-control-next {
32
- position: unset;
33
- width: fit-content;
34
- padding: rfs-value(6px);
35
- }
36
- .carousel-indicators {
37
- position: unset;
38
- margin-bottom: 0;
39
- margin-left: 0;
40
- @include margin-right($spacer * 1.5);
41
- [data-bs-target] {
42
- @extend .rounded-circle;
29
+ .carousel-control-prev,
30
+ .carousel-control-next {
31
+ position: unset;
32
+ width: fit-content;
33
+ padding: rfs-value(6px);
34
+ }
35
+ .carousel-indicators {
36
+ position: unset;
37
+ margin-bottom: 0;
38
+ margin-left: 0;
39
+ @include margin-right($spacer * 1.5);
40
+ [data-bs-target] {
41
+ @extend .rounded-circle;
42
+ }
43
43
  }
44
44
  }
@@ -1,2 +1,2 @@
1
1
  // GENERATED FILE – do not edit manually
2
- $km-version: "1.4.5" !default;
2
+ $km-version: "1.4.7" !default;