@astronox/core 0.4.0 → 0.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astronox/core",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Reusable Astro components, patterns, styles, and infrastructure for AstroNox sites.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -17,6 +17,12 @@ type Props = HTMLAttributes<'header'> &
17
17
  headingLevel?: HeadingLevel;
18
18
  sticky?: boolean;
19
19
  tone?: Tone;
20
+ trigger?: {
21
+ controls: string;
22
+ expanded: boolean;
23
+ hidden?: boolean;
24
+ label: string;
25
+ };
20
26
  };
21
27
 
22
28
  const {
@@ -29,6 +35,7 @@ const {
29
35
  headingLevel = 2,
30
36
  sticky = false,
31
37
  tone,
38
+ trigger,
32
39
  ...attrs
33
40
  }: Props = Astro.props;
34
41
  const HeadingTag = `h${headingLevel}` as const;
@@ -80,6 +87,19 @@ const resolvedTone = tone ?? dataTone;
80
87
  ) : null
81
88
  }
82
89
  </Inline>
90
+ {
91
+ trigger ? (
92
+ <button
93
+ class="card-header__trigger"
94
+ type="button"
95
+ aria-controls={trigger.controls}
96
+ aria-expanded={trigger.expanded}
97
+ aria-label={trigger.label}
98
+ hidden={trigger.hidden}
99
+ data-card-header-trigger
100
+ />
101
+ ) : null
102
+ }
83
103
  </div>
84
104
 
85
105
  {
@@ -90,12 +90,33 @@
90
90
  }
91
91
 
92
92
  .card-header__main {
93
+ position: relative;
93
94
  display: grid;
94
95
  min-width: 0;
95
96
  align-content: center;
96
97
  padding: var(--nox-space-3) var(--nox-space-5);
97
98
  }
98
99
 
100
+ .card-header__main:has(> .card-header__trigger) > [data-composition='inline'] {
101
+ pointer-events: none;
102
+ }
103
+
104
+ .card-header__trigger {
105
+ position: absolute;
106
+ z-index: 1;
107
+ inset: 0;
108
+ padding: 0;
109
+ border: 0;
110
+ background: transparent;
111
+ color: inherit;
112
+ cursor: pointer;
113
+ }
114
+
115
+ .card-header__trigger:focus-visible {
116
+ outline: var(--nox-focus-ring-width) solid var(--nox-tone-focus-ring);
117
+ outline-offset: calc(var(--nox-focus-ring-width) * -1);
118
+ }
119
+
99
120
  .card-header[data-has-end='true'] .card-header__main {
100
121
  padding-block: var(--nox-space-1);
101
122
  }
@@ -39,9 +39,8 @@ const resolvedTone = tone ?? dataTone;
39
39
  data-code-block-content
40
40
  data-code-block-presentation={presentation}
41
41
  data-wrap={wrap ? true : undefined}
42
- >
43
- {text}
44
- </pre>
42
+ set:text={text}
43
+ />
45
44
  <Button
46
45
  class="code-block__copy"
47
46
  type="button"
@@ -74,9 +73,8 @@ const resolvedTone = tone ?? dataTone;
74
73
  data-code-block-presentation={presentation}
75
74
  data-tone={resolvedTone}
76
75
  data-wrap={wrap ? true : undefined}
77
- >
78
- {text}
79
- </pre>
76
+ set:text={text}
77
+ />
80
78
  )
81
79
  }
82
80
 
@@ -72,6 +72,7 @@ export function initTooltip(root: HTMLElement): () => void {
72
72
  ':scope > [data-tooltip-panel]',
73
73
  );
74
74
  if (!trigger || !panel) return noop;
75
+ const activeTrigger = trigger;
75
76
 
76
77
  const hoverOnly = root.dataset.tooltipActivation === 'hover';
77
78
  root.setAttribute(initializedAttribute, 'true');
@@ -106,6 +107,7 @@ export function initTooltip(root: HTMLElement): () => void {
106
107
  let hoveredPanel = false;
107
108
  let openListenersActive = false;
108
109
  let pinned = false;
110
+ let lastPointerType = '';
109
111
 
110
112
  const isOpen = (): boolean => panel.matches(':popover-open');
111
113
 
@@ -239,12 +241,12 @@ export function initTooltip(root: HTMLElement): () => void {
239
241
  };
240
242
 
241
243
  const handleTriggerPointerEnter = (event: PointerEvent): void => {
242
- if (hoverOnly && event.pointerType === 'touch') return;
244
+ if (event.pointerType === 'touch') return;
243
245
  hoveredTrigger = true;
244
246
  show();
245
247
  };
246
248
  const handleTriggerPointerLeave = (event: PointerEvent): void => {
247
- if (hoverOnly && event.pointerType === 'touch') return;
249
+ if (event.pointerType === 'touch') return;
248
250
  hoveredTrigger = false;
249
251
 
250
252
  if (hoverOnly) {
@@ -263,6 +265,7 @@ export function initTooltip(root: HTMLElement): () => void {
263
265
  scheduleClose();
264
266
  };
265
267
  const handleFocusIn = (): void => {
268
+ if (lastPointerType === 'touch') return;
266
269
  if (
267
270
  hoverOnly &&
268
271
  !trigger.matches(':focus-visible') &&
@@ -274,7 +277,23 @@ export function initTooltip(root: HTMLElement): () => void {
274
277
  show();
275
278
  };
276
279
  const handleFocusOut = (): void => scheduleClose();
277
- const handleTriggerClick = (): void => {
280
+ const handleTriggerPointerDown = (event: PointerEvent): void => {
281
+ lastPointerType = event.pointerType;
282
+ };
283
+ const handleTriggerKeyDown = (): void => {
284
+ lastPointerType = '';
285
+ };
286
+ const handleTriggerClick = (event: MouseEvent): void => {
287
+ const pointerType =
288
+ 'PointerEvent' in window && event instanceof PointerEvent
289
+ ? event.pointerType
290
+ : lastPointerType;
291
+ if (hoverOnly && pointerType !== 'touch') return;
292
+ if (pointerType === 'touch') {
293
+ event.preventDefault();
294
+ event.stopPropagation();
295
+ }
296
+
278
297
  if (pinned) {
279
298
  close();
280
299
  return;
@@ -299,6 +318,12 @@ export function initTooltip(root: HTMLElement): () => void {
299
318
  if (!(event instanceof CustomEvent) || event.detail?.root === root) return;
300
319
  close();
301
320
  };
321
+ const visibilityObserver =
322
+ 'IntersectionObserver' in window
323
+ ? new IntersectionObserver((entries) => {
324
+ if (entries.some((entry) => !entry.isIntersecting)) close();
325
+ })
326
+ : null;
302
327
  function addOpenListeners(): void {
303
328
  if (openListenersActive) return;
304
329
  openListenersActive = true;
@@ -309,6 +334,7 @@ export function initTooltip(root: HTMLElement): () => void {
309
334
  window.addEventListener('scroll', requestPosition, true);
310
335
  window.visualViewport?.addEventListener('resize', requestPosition);
311
336
  window.visualViewport?.addEventListener('scroll', requestPosition);
337
+ visibilityObserver?.observe(activeTrigger);
312
338
  }
313
339
  function removeOpenListeners(): void {
314
340
  if (!openListenersActive) return;
@@ -320,13 +346,16 @@ export function initTooltip(root: HTMLElement): () => void {
320
346
  window.removeEventListener('scroll', requestPosition, true);
321
347
  window.visualViewport?.removeEventListener('resize', requestPosition);
322
348
  window.visualViewport?.removeEventListener('scroll', requestPosition);
349
+ visibilityObserver?.unobserve(activeTrigger);
323
350
  }
324
351
 
352
+ trigger.addEventListener('pointerdown', handleTriggerPointerDown);
325
353
  trigger.addEventListener('pointerenter', handleTriggerPointerEnter);
326
354
  trigger.addEventListener('pointerleave', handleTriggerPointerLeave);
327
355
  trigger.addEventListener('focusin', handleFocusIn);
328
356
  trigger.addEventListener('focusout', handleFocusOut);
329
- if (!hoverOnly) trigger.addEventListener('click', handleTriggerClick);
357
+ trigger.addEventListener('keydown', handleTriggerKeyDown);
358
+ trigger.addEventListener('click', handleTriggerClick);
330
359
  if (!hoverOnly) {
331
360
  panel.addEventListener('pointerenter', handlePanelPointerEnter);
332
361
  panel.addEventListener('pointerleave', handlePanelPointerLeave);
@@ -338,11 +367,14 @@ export function initTooltip(root: HTMLElement): () => void {
338
367
  if (positionFrame !== undefined) {
339
368
  window.cancelAnimationFrame(positionFrame);
340
369
  }
370
+ visibilityObserver?.disconnect();
371
+ trigger.removeEventListener('pointerdown', handleTriggerPointerDown);
341
372
  trigger.removeEventListener('pointerenter', handleTriggerPointerEnter);
342
373
  trigger.removeEventListener('pointerleave', handleTriggerPointerLeave);
343
374
  trigger.removeEventListener('focusin', handleFocusIn);
344
375
  trigger.removeEventListener('focusout', handleFocusOut);
345
- if (!hoverOnly) trigger.removeEventListener('click', handleTriggerClick);
376
+ trigger.removeEventListener('keydown', handleTriggerKeyDown);
377
+ trigger.removeEventListener('click', handleTriggerClick);
346
378
  if (!hoverOnly) {
347
379
  panel.removeEventListener('pointerenter', handlePanelPointerEnter);
348
380
  panel.removeEventListener('pointerleave', handlePanelPointerLeave);
@@ -16,6 +16,7 @@ const {
16
16
  'data-tone': dataTone,
17
17
  items,
18
18
  label = defaultCoreMessages.navigation.breadcrumbs,
19
+ tabindex: tabIndex = 0,
19
20
  tone,
20
21
  ...attrs
21
22
  } = Astro.props;
@@ -28,6 +29,7 @@ const resolvedTone = tone ?? dataTone ?? 'info';
28
29
  {...attrs}
29
30
  class:list={['breadcrumbs', className]}
30
31
  aria-label={label}
32
+ tabindex={tabIndex}
31
33
  data-widget="breadcrumbs"
32
34
  data-tone={resolvedTone}
33
35
  >
@@ -1,17 +1,26 @@
1
1
  .breadcrumbs {
2
2
  min-width: 0;
3
- overflow: hidden;
3
+ overflow-x: auto;
4
+ overflow-y: hidden;
5
+ overscroll-behavior-inline: contain;
6
+ scrollbar-width: thin;
7
+ touch-action: pan-x pan-y;
4
8
  color: var(--nox-tone-color);
5
9
  font-size: var(--nox-font-size-sm);
6
10
  font-weight: var(--nox-font-weight-medium);
7
11
  line-height: 1.35;
8
12
  }
9
13
 
14
+ .breadcrumbs:focus-visible {
15
+ outline: var(--nox-focus-ring-width) solid var(--nox-tone-focus-ring);
16
+ outline-offset: calc(var(--nox-focus-ring-width) * -1);
17
+ }
18
+
10
19
  .breadcrumbs__list {
11
20
  display: flex;
12
21
  flex-wrap: nowrap;
13
22
  align-items: stretch;
14
- min-width: 0;
23
+ min-width: max-content;
15
24
  min-block-size: calc(
16
25
  1.35em + var(--site-main-content-gap, var(--nox-space-5)) +
17
26
  var(--site-main-content-gap, var(--nox-space-5))
@@ -30,8 +39,7 @@
30
39
  }
31
40
 
32
41
  .breadcrumbs__item:last-child {
33
- flex: 1 1 auto;
34
- overflow: hidden;
42
+ flex: 0 0 auto;
35
43
  }
36
44
 
37
45
  .breadcrumbs__item > span {
@@ -63,8 +71,6 @@
63
71
  }
64
72
 
65
73
  .breadcrumbs__item:last-child > :is(a, span) {
66
- overflow: hidden;
67
- text-overflow: ellipsis;
68
74
  white-space: nowrap;
69
75
  }
70
76
 
@@ -1,5 +1,5 @@
1
1
  .grid-container {
2
- container-type: inline-size;
2
+ container: nox-grid / inline-size;
3
3
  min-width: 0;
4
4
  }
5
5
 
@@ -17,24 +17,32 @@
17
17
  }
18
18
 
19
19
  .surface-group:has(> [data-sticky-surface])::before {
20
- --surface-group-sticky-occlusion-size: calc(
21
- var(--nox-layout-gap) + var(--nox-section-responsive-block-size)
22
- );
23
-
24
20
  position: sticky;
25
21
  z-index: 39;
26
22
  top: 0;
27
23
  display: block;
28
24
  min-width: 0;
29
- min-height: var(--surface-group-sticky-occlusion-size);
25
+ min-height: var(
26
+ --surface-group-sticky-occlusion-size,
27
+ calc(var(--nox-layout-gap) + var(--nox-section-responsive-block-size))
28
+ );
30
29
  visibility: hidden;
31
- flex: 0 0 var(--surface-group-sticky-occlusion-size);
32
- margin-block-end: calc(-1 * var(--surface-group-sticky-occlusion-size));
30
+ flex: 0 0
31
+ var(
32
+ --surface-group-sticky-occlusion-size,
33
+ calc(var(--nox-layout-gap) + var(--nox-section-responsive-block-size))
34
+ );
35
+ margin-block-end: calc(
36
+ -1 *
37
+ var(
38
+ --surface-group-sticky-occlusion-size,
39
+ calc(var(--nox-layout-gap) + var(--nox-section-responsive-block-size))
40
+ )
41
+ );
33
42
  background: var(--nox-page-background);
34
43
  content: '';
35
44
  }
36
45
 
37
- .surface-group:has(> [data-sticky-surface]):not([data-sticky-measured])::before,
38
46
  .surface-group[data-sticky-active]::before {
39
47
  visibility: visible;
40
48
  }
@@ -61,8 +69,8 @@
61
69
 
62
70
  .surface-group
63
71
  > [data-surface]:not([hidden]):has(~ [data-surface]:not([hidden])) {
64
- border-end-start-radius: 0;
65
- border-end-end-radius: 0;
72
+ border-end-start-radius: var(--surface-group-connected-border-end-radius, 0);
73
+ border-end-end-radius: var(--surface-group-connected-border-end-radius, 0);
66
74
  }
67
75
 
68
76
  .surface-group > [data-surface]:not([hidden]) ~ [data-surface]:not([hidden]) {
@@ -70,11 +78,3 @@
70
78
  border-start-start-radius: 0;
71
79
  border-start-end-radius: 0;
72
80
  }
73
-
74
- @container nox-surface-group (width < 32rem) {
75
- .surface-group:has(> [data-sticky-surface])::before {
76
- --surface-group-sticky-occlusion-size: calc(
77
- var(--nox-layout-gap) + var(--nox-section-compact-block-size)
78
- );
79
- }
80
- }
@@ -46,6 +46,10 @@ export const initStickySurfaceGroup = (root: HTMLElement): (() => void) => {
46
46
 
47
47
  const stickyOffset =
48
48
  Number.parseFloat(window.getComputedStyle(stickySurface).top) || 0;
49
+ root.style.setProperty(
50
+ '--surface-group-sticky-occlusion-size',
51
+ `${stickyOffset + stickySurface.getBoundingClientRect().height}px`,
52
+ );
49
53
  observer = new IntersectionObserver(
50
54
  ([entry]) => {
51
55
  if (!entry) return;
@@ -74,10 +78,14 @@ export const initStickySurfaceGroup = (root: HTMLElement): (() => void) => {
74
78
 
75
79
  root.setAttribute('data-sticky-initialized', 'true');
76
80
  window.addEventListener('resize', scheduleObserve, { passive: true });
81
+ const resizeObserver =
82
+ 'ResizeObserver' in window ? new ResizeObserver(scheduleObserve) : null;
83
+ resizeObserver?.observe(stickySurface);
77
84
  observe();
78
85
 
79
86
  return () => {
80
87
  window.removeEventListener('resize', scheduleObserve);
88
+ resizeObserver?.disconnect();
81
89
  observer?.disconnect();
82
90
  sentinel.remove();
83
91
 
@@ -88,5 +96,6 @@ export const initStickySurfaceGroup = (root: HTMLElement): (() => void) => {
88
96
  root.removeAttribute('data-sticky-active');
89
97
  root.removeAttribute('data-sticky-initialized');
90
98
  root.removeAttribute('data-sticky-measured');
99
+ root.style.removeProperty('--surface-group-sticky-occlusion-size');
91
100
  };
92
101
  };
@@ -352,6 +352,7 @@ export function initCombobox(root: HTMLElement): () => void {
352
352
  let activeOption: HTMLButtonElement | null = null;
353
353
  let positionFrame: number | null = null;
354
354
  let viewportListenersAttached = false;
355
+ let openAtPointerDown: boolean | null = null;
355
356
 
356
357
  const isOpen = (): boolean => root.hasAttribute('data-combobox-open');
357
358
 
@@ -391,6 +392,18 @@ export function initCombobox(root: HTMLElement): () => void {
391
392
  }
392
393
  };
393
394
 
395
+ const scrollOptionIntoView = (option: HTMLElement | null): void => {
396
+ if (!option) return;
397
+ const optionsRect = optionsRoot.getBoundingClientRect();
398
+ const optionRect = option.getBoundingClientRect();
399
+
400
+ if (optionRect.top < optionsRect.top) {
401
+ optionsRoot.scrollTop -= optionsRect.top - optionRect.top;
402
+ } else if (optionRect.bottom > optionsRect.bottom) {
403
+ optionsRoot.scrollTop += optionRect.bottom - optionsRect.bottom;
404
+ }
405
+ };
406
+
394
407
  const positionPanel = (): void => {
395
408
  if (!isOpen()) return;
396
409
 
@@ -490,7 +503,13 @@ export function initCombobox(root: HTMLElement): () => void {
490
503
  const target = event.target;
491
504
  if (target instanceof Node && panel.contains(target)) return;
492
505
 
493
- if (window.matchMedia(compactViewportQuery).matches) {
506
+ const siteViewport = document.querySelector<HTMLElement>(
507
+ '[data-site-viewport]',
508
+ );
509
+ const isSiteScroll = siteViewport
510
+ ? target === siteViewport
511
+ : target === document || target === window;
512
+ if (isSiteScroll && window.matchMedia(compactViewportQuery).matches) {
494
513
  close();
495
514
  blurFocusedDescendant();
496
515
  return;
@@ -547,6 +566,10 @@ export function initCombobox(root: HTMLElement): () => void {
547
566
  resetFilter();
548
567
  if (panel.matches(':popover-open')) panel.hidePopover();
549
568
  panel.hidden = true;
569
+ panel.style.removeProperty('--combobox-panel-inline-start');
570
+ panel.style.removeProperty('--combobox-panel-block-start');
571
+ panel.style.removeProperty('--combobox-panel-inline-size');
572
+ panel.style.removeProperty('--combobox-panel-available-block-size');
550
573
 
551
574
  if (restoreFocus) owner.focus();
552
575
  };
@@ -573,7 +596,7 @@ export function initCombobox(root: HTMLElement): () => void {
573
596
  (option) => option.getAttribute('aria-selected') === 'true',
574
597
  ) ?? null;
575
598
  setActiveOption(null);
576
- selectedOption?.scrollIntoView({ block: 'nearest' });
599
+ scrollOptionIntoView(selectedOption);
577
600
 
578
601
  if (focusSearch && search) {
579
602
  window.requestAnimationFrame(() => {
@@ -601,7 +624,7 @@ export function initCombobox(root: HTMLElement): () => void {
601
624
  visibleOptions.length;
602
625
  const nextOption = visibleOptions[nextIndex] ?? null;
603
626
  setActiveOption(nextOption);
604
- nextOption?.scrollIntoView({ block: 'nearest' });
627
+ scrollOptionIntoView(nextOption);
605
628
  };
606
629
 
607
630
  const selectOption = (
@@ -687,13 +710,23 @@ export function initCombobox(root: HTMLElement): () => void {
687
710
  };
688
711
 
689
712
  const toggleControl = (restoreFocus = true): void => {
690
- if (isOpen()) {
713
+ const wasOpen = openAtPointerDown ?? isOpen();
714
+ openAtPointerDown = null;
715
+ if (wasOpen) {
691
716
  close(restoreFocus);
692
717
  } else {
693
718
  open();
694
719
  }
695
720
  };
696
721
 
722
+ const handleOwnerPointerDown = (): void => {
723
+ openAtPointerDown = isOpen();
724
+ };
725
+
726
+ const handleOwnerPointerCancel = (): void => {
727
+ openAtPointerDown = null;
728
+ };
729
+
697
730
  const handleOptionsTriggerClick = (): void => {
698
731
  toggleControl(false);
699
732
  };
@@ -703,6 +736,7 @@ export function initCombobox(root: HTMLElement): () => void {
703
736
  };
704
737
 
705
738
  const handleOwnerKeydown = (event: KeyboardEvent): void => {
739
+ openAtPointerDown = null;
706
740
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
707
741
  event.preventDefault();
708
742
  if (!isOpen()) {
@@ -799,12 +833,9 @@ export function initCombobox(root: HTMLElement): () => void {
799
833
  const handleNumberInputClick = (): void => {
800
834
  if (!numberInput) return;
801
835
 
802
- if (isOpen()) {
803
- close();
804
- } else {
805
- open(false);
806
- numberInput.select();
807
- }
836
+ const wasOpen = openAtPointerDown ?? isOpen();
837
+ toggleControl(false);
838
+ if (!wasOpen) numberInput.select();
808
839
  };
809
840
 
810
841
  const handleNumberInput = (): void => {
@@ -825,7 +856,7 @@ export function initCombobox(root: HTMLElement): () => void {
825
856
  });
826
857
  }
827
858
  setActiveOption(matchingOption ?? null);
828
- matchingOption?.scrollIntoView({ block: 'nearest' });
859
+ scrollOptionIntoView(matchingOption ?? null);
829
860
  };
830
861
 
831
862
  const handleNumberInputChange = (): void => {
@@ -851,7 +882,7 @@ export function initCombobox(root: HTMLElement): () => void {
851
882
  (option) => option.getAttribute('aria-selected') === 'true',
852
883
  ) ?? null;
853
884
  setActiveOption(selectedOption);
854
- selectedOption?.scrollIntoView({ block: 'nearest' });
885
+ scrollOptionIntoView(selectedOption);
855
886
  }
856
887
  };
857
888
 
@@ -877,6 +908,8 @@ export function initCombobox(root: HTMLElement): () => void {
877
908
 
878
909
  if (mode === 'options' && trigger && search) {
879
910
  control.addEventListener('click', handleOptionsControlClick);
911
+ trigger.addEventListener('pointerdown', handleOwnerPointerDown);
912
+ trigger.addEventListener('pointercancel', handleOwnerPointerCancel);
880
913
  trigger.addEventListener('click', handleOptionsTriggerClick);
881
914
  trigger.addEventListener('keydown', handleOwnerKeydown);
882
915
  search.addEventListener('input', filterOptions);
@@ -884,6 +917,8 @@ export function initCombobox(root: HTMLElement): () => void {
884
917
  }
885
918
 
886
919
  if (mode === 'number' && numberInput) {
920
+ numberInput.addEventListener('pointerdown', handleOwnerPointerDown);
921
+ numberInput.addEventListener('pointercancel', handleOwnerPointerCancel);
887
922
  numberInput.addEventListener('click', handleNumberInputClick);
888
923
  numberInput.addEventListener('input', handleNumberInput);
889
924
  numberInput.addEventListener('change', handleNumberInputChange);
@@ -908,10 +943,14 @@ export function initCombobox(root: HTMLElement): () => void {
908
943
  detachViewportListeners();
909
944
  controlResizeObserver?.disconnect();
910
945
  control.removeEventListener('click', handleOptionsControlClick);
946
+ trigger?.removeEventListener('pointerdown', handleOwnerPointerDown);
947
+ trigger?.removeEventListener('pointercancel', handleOwnerPointerCancel);
911
948
  trigger?.removeEventListener('click', handleOptionsTriggerClick);
912
949
  trigger?.removeEventListener('keydown', handleOwnerKeydown);
913
950
  search?.removeEventListener('input', filterOptions);
914
951
  search?.removeEventListener('keydown', handleSearchKeydown);
952
+ numberInput?.removeEventListener('pointerdown', handleOwnerPointerDown);
953
+ numberInput?.removeEventListener('pointercancel', handleOwnerPointerCancel);
915
954
  numberInput?.removeEventListener('click', handleNumberInputClick);
916
955
  numberInput?.removeEventListener('input', handleNumberInput);
917
956
  numberInput?.removeEventListener('change', handleNumberInputChange);
@@ -25,6 +25,7 @@ export function initDropdown(root: HTMLElement): () => void {
25
25
  const triggerMode = getTriggerMode(root);
26
26
  const hoverMedia = window.matchMedia('(hover: hover)');
27
27
  let isOpen = !content.hidden;
28
+ let openAtPointerDown: boolean | null = null;
28
29
 
29
30
  const canOpenOnHover = (): boolean =>
30
31
  triggerMode === 'hover' && hoverMedia.matches;
@@ -61,12 +62,17 @@ export function initDropdown(root: HTMLElement): () => void {
61
62
  };
62
63
 
63
64
  const handleTriggerClick = (): void => {
64
- if (canOpenOnHover()) {
65
- setOpen(true);
66
- return;
67
- }
65
+ const wasOpen = openAtPointerDown ?? isOpen;
66
+ openAtPointerDown = null;
67
+ setOpen(!wasOpen);
68
+ };
69
+
70
+ const handleTriggerPointerDown = (): void => {
71
+ openAtPointerDown = isOpen;
72
+ };
68
73
 
69
- setOpen(!isOpen);
74
+ const handleTriggerPointerCancel = (): void => {
75
+ openAtPointerDown = null;
70
76
  };
71
77
 
72
78
  const handleRootPointerEnter = (): void => {
@@ -88,12 +94,13 @@ export function initDropdown(root: HTMLElement): () => void {
88
94
  };
89
95
 
90
96
  const handleTriggerFocus = (): void => {
91
- if (triggerMode === 'hover') {
97
+ if (canOpenOnHover()) {
92
98
  setOpen(true);
93
99
  }
94
100
  };
95
101
 
96
102
  const handleTriggerKeydown = (event: KeyboardEvent): void => {
103
+ openAtPointerDown = null;
97
104
  if (event.key === 'ArrowDown') {
98
105
  event.preventDefault();
99
106
  openAndFocusFirstItem();
@@ -131,6 +138,8 @@ export function initDropdown(root: HTMLElement): () => void {
131
138
 
132
139
  setOpen(isOpen);
133
140
 
141
+ trigger.addEventListener('pointerdown', handleTriggerPointerDown);
142
+ trigger.addEventListener('pointercancel', handleTriggerPointerCancel);
134
143
  trigger.addEventListener('click', handleTriggerClick);
135
144
  trigger.addEventListener('focus', handleTriggerFocus);
136
145
  trigger.addEventListener('keydown', handleTriggerKeydown);
@@ -143,6 +152,8 @@ export function initDropdown(root: HTMLElement): () => void {
143
152
 
144
153
  return () => {
145
154
  close();
155
+ trigger.removeEventListener('pointerdown', handleTriggerPointerDown);
156
+ trigger.removeEventListener('pointercancel', handleTriggerPointerCancel);
146
157
  trigger.removeEventListener('click', handleTriggerClick);
147
158
  trigger.removeEventListener('focus', handleTriggerFocus);
148
159
  trigger.removeEventListener('keydown', handleTriggerKeydown);