@boostdev/design-system-components 1.2.0 → 1.2.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.
Files changed (168) hide show
  1. package/AGENTS.md +46 -4
  2. package/README.md +15 -15
  3. package/dist/client.cjs +149 -124
  4. package/dist/client.css +503 -525
  5. package/dist/client.d.cts +71 -96
  6. package/dist/client.d.ts +71 -96
  7. package/dist/client.js +149 -124
  8. package/dist/index.cjs +149 -124
  9. package/dist/index.css +503 -525
  10. package/dist/index.d.cts +71 -96
  11. package/dist/index.d.ts +71 -96
  12. package/dist/index.js +149 -124
  13. package/dist/web-components/{chunk-6MH5UWUD.js → chunk-2FGATTGT.js} +5 -0
  14. package/dist/web-components/{chunk-5IPHEONG.js → chunk-6I2DBFQ7.js} +1 -0
  15. package/dist/web-components/{chunk-DI46Q2EA.js → chunk-OCODKRVZ.js} +57 -82
  16. package/dist/web-components/index.d.ts +571 -1
  17. package/dist/web-components/index.js +1501 -4
  18. package/dist/web-components/interaction/bds-collapsible.js +1 -1
  19. package/dist/web-components/interaction/bds-drawer.js +1 -1
  20. package/dist/web-components/interaction/form/bds-segmented-control.js +1 -1
  21. package/package.json +1 -1
  22. package/src/components/interaction/Command/Command.tsx +4 -3
  23. package/src/components/interaction/Dialog/Dialog.tsx +4 -5
  24. package/src/components/interaction/Drawer/Drawer.module.css +37 -62
  25. package/src/components/interaction/Drawer/Drawer.spec.tsx +3 -3
  26. package/src/components/interaction/Drawer/Drawer.tsx +25 -24
  27. package/src/components/interaction/DropdownMenu/DropdownMenu.tsx +4 -2
  28. package/src/components/interaction/Popover/Popover.tsx +4 -3
  29. package/src/components/interaction/Rating/Rating.tsx +4 -2
  30. package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.tsx +4 -6
  31. package/src/components/interaction/form/Combobox/Combobox.tsx +4 -2
  32. package/src/components/interaction/form/FileInput/FileInput.tsx +4 -3
  33. package/src/components/interaction/form/NumberInput/NumberInput.tsx +4 -3
  34. package/src/components/interaction/form/RadioGroup/RadioGroup.tsx +4 -6
  35. package/src/components/interaction/form/SegmentedControl/SegmentedControl.mdx +8 -4
  36. package/src/components/interaction/form/SegmentedControl/SegmentedControl.module.css +13 -6
  37. package/src/components/interaction/form/SegmentedControl/SegmentedControl.tsx +4 -6
  38. package/src/components/interaction/form/atoms/InputContainer.tsx +2 -2
  39. package/src/components/layout/ButtonGroup/ButtonGroup.tsx +4 -6
  40. package/src/components/layout/Card/Card.tsx +4 -10
  41. package/src/components/layout/IconWrapper/IconWrapper.tsx +4 -5
  42. package/src/components/layout/SectionHeader/SectionHeader.tsx +5 -4
  43. package/src/components/ui/Accordion/Accordion.tsx +4 -3
  44. package/src/components/ui/Alert/Alert.tsx +4 -3
  45. package/src/components/ui/Avatar/Avatar.tsx +5 -3
  46. package/src/components/ui/Badge/Badge.tsx +4 -5
  47. package/src/components/ui/Breadcrumb/Breadcrumb.tsx +4 -3
  48. package/src/components/ui/Calendar/Calendar.tsx +4 -4
  49. package/src/components/ui/Carousel/Carousel.tsx +4 -4
  50. package/src/components/ui/DescriptionList/DescriptionList.tsx +4 -4
  51. package/src/components/ui/Loading/Loading.tsx +4 -3
  52. package/src/components/ui/NotificationBanner/NotificationBanner.tsx +4 -2
  53. package/src/components/ui/Pagination/Pagination.tsx +4 -2
  54. package/src/components/ui/Progress/Progress.tsx +4 -2
  55. package/src/components/ui/ProgressCircle/ProgressCircle.tsx +4 -1
  56. package/src/components/ui/Separator/Separator.tsx +5 -3
  57. package/src/components/ui/Skeleton/Skeleton.tsx +4 -3
  58. package/src/components/ui/SkipLink/SkipLink.tsx +4 -5
  59. package/src/components/ui/Tabs/Tabs.tsx +4 -4
  60. package/src/components/ui/Tooltip/Tooltip.tsx +4 -2
  61. package/src/components/ui/Typography/Typography.tsx +4 -5
  62. package/src/stories/DesignSystem/Grid.mdx +2 -0
  63. package/src/stories/Introduction.mdx +2 -1
  64. package/src/web-components/index.ts +12 -0
  65. package/src/web-components/interaction/BdsAccordion.mdx +80 -28
  66. package/src/web-components/interaction/BdsAccordion.stories.tsx +67 -58
  67. package/src/web-components/interaction/BdsCollapsible.mdx +93 -23
  68. package/src/web-components/interaction/BdsCollapsible.stories.tsx +29 -48
  69. package/src/web-components/interaction/BdsDialog.mdx +88 -27
  70. package/src/web-components/interaction/BdsDialog.stories.tsx +129 -47
  71. package/src/web-components/interaction/BdsDrawer.mdx +85 -27
  72. package/src/web-components/interaction/BdsDrawer.stories.tsx +161 -31
  73. package/src/web-components/interaction/BdsDropdownMenu.mdx +108 -0
  74. package/src/web-components/interaction/BdsDropdownMenu.stories.tsx +91 -0
  75. package/src/web-components/interaction/BdsSkipLink.mdx +72 -16
  76. package/src/web-components/interaction/BdsSkipLink.stories.tsx +47 -34
  77. package/src/web-components/interaction/BdsTabs.mdx +97 -27
  78. package/src/web-components/interaction/BdsTabs.stories.tsx +69 -55
  79. package/src/web-components/interaction/BdsTooltip.mdx +84 -18
  80. package/src/web-components/interaction/BdsTooltip.stories.tsx +49 -30
  81. package/src/web-components/interaction/bds-collapsible.ts +1 -0
  82. package/src/web-components/interaction/bds-drawer.ts +59 -82
  83. package/src/web-components/interaction/bds-dropdown-menu-item.ts +124 -0
  84. package/src/web-components/interaction/bds-dropdown-menu.spec.ts +102 -0
  85. package/src/web-components/interaction/bds-dropdown-menu.ts +200 -0
  86. package/src/web-components/interaction/form/BdsCheckbox.mdx +57 -20
  87. package/src/web-components/interaction/form/BdsCheckbox.stories.tsx +35 -36
  88. package/src/web-components/interaction/form/BdsCheckboxGroup.mdx +70 -0
  89. package/src/web-components/interaction/form/BdsCheckboxGroup.stories.tsx +81 -0
  90. package/src/web-components/interaction/form/BdsCombobox.mdx +52 -33
  91. package/src/web-components/interaction/form/BdsCombobox.stories.tsx +55 -53
  92. package/src/web-components/interaction/form/BdsFileInput.mdx +59 -24
  93. package/src/web-components/interaction/form/BdsFileInput.stories.tsx +38 -53
  94. package/src/web-components/interaction/form/BdsFormInput.mdx +98 -0
  95. package/src/web-components/interaction/form/BdsFormInput.stories.tsx +139 -0
  96. package/src/web-components/interaction/form/BdsNumberInput.mdx +51 -22
  97. package/src/web-components/interaction/form/BdsNumberInput.stories.tsx +24 -46
  98. package/src/web-components/interaction/form/BdsRadio.mdx +53 -22
  99. package/src/web-components/interaction/form/BdsRadio.stories.tsx +19 -51
  100. package/src/web-components/interaction/form/BdsRadioGroup.mdx +73 -0
  101. package/src/web-components/interaction/form/BdsRadioGroup.stories.tsx +88 -0
  102. package/src/web-components/interaction/form/BdsSegmentedControl.mdx +76 -23
  103. package/src/web-components/interaction/form/BdsSegmentedControl.stories.tsx +64 -54
  104. package/src/web-components/interaction/form/BdsSelect.mdx +59 -21
  105. package/src/web-components/interaction/form/BdsSelect.stories.tsx +47 -56
  106. package/src/web-components/interaction/form/BdsSlider.mdx +55 -27
  107. package/src/web-components/interaction/form/BdsSlider.stories.tsx +23 -44
  108. package/src/web-components/interaction/form/BdsSwitch.mdx +53 -20
  109. package/src/web-components/interaction/form/BdsSwitch.stories.tsx +22 -37
  110. package/src/web-components/interaction/form/BdsTextarea.mdx +54 -22
  111. package/src/web-components/interaction/form/BdsTextarea.stories.tsx +35 -44
  112. package/src/web-components/interaction/form/bds-checkbox-group.spec.ts +55 -0
  113. package/src/web-components/interaction/form/bds-checkbox-group.ts +117 -0
  114. package/src/web-components/interaction/form/bds-form-input.spec.ts +83 -0
  115. package/src/web-components/interaction/form/bds-form-input.ts +268 -0
  116. package/src/web-components/interaction/form/bds-radio-group.spec.ts +62 -0
  117. package/src/web-components/interaction/form/bds-radio-group.ts +142 -0
  118. package/src/web-components/interaction/form/bds-segmented-control.ts +5 -0
  119. package/src/web-components/ui/BdsAvatar.mdx +29 -20
  120. package/src/web-components/ui/BdsAvatar.stories.tsx +14 -28
  121. package/src/web-components/ui/BdsBreadcrumb.mdx +22 -19
  122. package/src/web-components/ui/BdsBreadcrumb.stories.tsx +11 -21
  123. package/src/web-components/ui/BdsButtonGroup.mdx +69 -0
  124. package/src/web-components/ui/BdsButtonGroup.stories.tsx +66 -0
  125. package/src/web-components/ui/BdsCalendar.mdx +93 -0
  126. package/src/web-components/ui/BdsCalendar.stories.tsx +63 -0
  127. package/src/web-components/ui/BdsCard.mdx +30 -25
  128. package/src/web-components/ui/BdsCard.stories.tsx +19 -42
  129. package/src/web-components/ui/BdsCarousel.mdx +83 -0
  130. package/src/web-components/ui/BdsCarousel.stories.tsx +96 -0
  131. package/src/web-components/ui/BdsDescriptionList.mdx +13 -16
  132. package/src/web-components/ui/BdsDescriptionList.stories.tsx +34 -49
  133. package/src/web-components/ui/BdsIconWrapper.mdx +20 -20
  134. package/src/web-components/ui/BdsIconWrapper.stories.tsx +46 -41
  135. package/src/web-components/ui/BdsLink.mdx +21 -17
  136. package/src/web-components/ui/BdsLink.stories.tsx +19 -20
  137. package/src/web-components/ui/BdsLoading.mdx +9 -13
  138. package/src/web-components/ui/BdsLoading.stories.tsx +8 -10
  139. package/src/web-components/ui/BdsNotificationBanner.mdx +32 -37
  140. package/src/web-components/ui/BdsNotificationBanner.stories.tsx +38 -47
  141. package/src/web-components/ui/BdsPagination.mdx +74 -0
  142. package/src/web-components/ui/BdsPagination.stories.tsx +36 -0
  143. package/src/web-components/ui/BdsProgress.mdx +24 -17
  144. package/src/web-components/ui/BdsProgress.stories.tsx +55 -29
  145. package/src/web-components/ui/BdsProgressCircle.mdx +24 -18
  146. package/src/web-components/ui/BdsProgressCircle.stories.tsx +17 -29
  147. package/src/web-components/ui/BdsRating.mdx +22 -22
  148. package/src/web-components/ui/BdsRating.stories.tsx +19 -13
  149. package/src/web-components/ui/BdsSectionHeader.mdx +31 -28
  150. package/src/web-components/ui/BdsSectionHeader.stories.tsx +21 -23
  151. package/src/web-components/ui/BdsSeparator.mdx +22 -8
  152. package/src/web-components/ui/BdsSeparator.stories.tsx +27 -19
  153. package/src/web-components/ui/BdsSkeleton.mdx +20 -26
  154. package/src/web-components/ui/BdsSkeleton.stories.tsx +23 -23
  155. package/src/web-components/ui/BdsTable.mdx +81 -0
  156. package/src/web-components/ui/BdsTable.stories.tsx +83 -0
  157. package/src/web-components/ui/BdsTypography.mdx +20 -29
  158. package/src/web-components/ui/BdsTypography.stories.tsx +19 -18
  159. package/src/web-components/ui/bds-button-group.spec.ts +40 -0
  160. package/src/web-components/ui/bds-button-group.ts +78 -0
  161. package/src/web-components/ui/bds-calendar.spec.ts +91 -0
  162. package/src/web-components/ui/bds-calendar.ts +427 -0
  163. package/src/web-components/ui/bds-carousel.spec.ts +64 -0
  164. package/src/web-components/ui/bds-carousel.ts +296 -0
  165. package/src/web-components/ui/bds-pagination.spec.ts +67 -0
  166. package/src/web-components/ui/bds-pagination.ts +197 -0
  167. package/src/web-components/ui/bds-table.spec.ts +45 -0
  168. package/src/web-components/ui/bds-table.ts +96 -0
@@ -37,85 +37,65 @@ export class BdsDrawer extends LitElement {
37
37
 
38
38
  .drawer {
39
39
  position: fixed;
40
- inset: 0;
40
+ inset-block: 0;
41
+ inset-inline: auto 0;
42
+ inline-size: min(28rem, 90vw);
43
+ max-inline-size: 100vw;
44
+ block-size: 100dvh;
45
+ max-block-size: 100dvh;
41
46
  margin: 0;
42
47
  padding: 0;
43
- inline-size: 100%;
44
- max-inline-size: 100%;
45
- block-size: 100%;
46
- max-block-size: 100%;
47
- border: none;
48
- background: transparent;
49
- overflow: hidden;
50
- transition:
51
- display var(--bds-animation_transition-duration) allow-discrete,
52
- overlay var(--bds-animation_transition-duration) allow-discrete;
53
- }
54
-
55
- .drawer::backdrop {
56
- background-color: var(--color_backdrop, rgb(0 0 0 / 50%));
57
- backdrop-filter: blur(3px);
58
- transition:
59
- display var(--bds-animation_transition-duration) allow-discrete,
60
- overlay var(--bds-animation_transition-duration) allow-discrete,
61
- background-color var(--bds-animation_transition-duration) var(--bds-animation_easing),
62
- backdrop-filter var(--bds-animation_transition-duration) var(--bds-animation_easing);
63
- }
64
-
65
- @starting-style {
66
- .drawer[open]::backdrop {
67
- background-color: transparent;
68
- backdrop-filter: blur(0);
69
- }
70
- }
71
-
72
- .panel {
73
- position: absolute;
74
- inset-block: 0;
75
48
  display: flex;
76
49
  flex-direction: column;
77
- inline-size: min(28rem, 90vw);
78
- block-size: 100%;
79
50
  background-color: var(--drawer_color, var(--bds-color_bg));
80
51
  color: var(--drawer_on-color, var(--bds-color_on-bg));
52
+ border: none;
81
53
  box-shadow: var(--bds-shadow_xl);
82
54
  overflow: hidden;
83
55
  translate: 100% 0;
84
- transition: translate var(--bds-animation_transition-duration) var(--bds-animation_easing);
85
- }
86
-
87
- :host([side='right']) .panel {
88
- inset-inline-end: 0;
89
- translate: 100% 0;
56
+ transition:
57
+ translate var(--bds-animation_transition-duration) var(--bds-animation_easing),
58
+ display var(--bds-animation_transition-duration) var(--bds-animation_easing) allow-discrete,
59
+ overlay var(--bds-animation_transition-duration) var(--bds-animation_easing) allow-discrete;
90
60
  }
91
61
 
92
- :host([side='left']) .panel,
93
- :host(:not([side])) .panel {
94
- inset-inline-start: 0;
62
+ :host([side='left']) .drawer {
63
+ inset-inline: 0 auto;
95
64
  translate: -100% 0;
96
65
  }
97
66
 
98
- .drawer[open] .panel {
67
+ .drawer[open] {
99
68
  translate: 0 0;
100
69
  }
101
70
 
102
- :host([side='right']) .drawer[open] .panel,
103
- :host(:not([side])) .drawer[open] .panel {
104
- animation: bds-drawer-slide-right var(--bds-animation_transition-duration) var(--bds-animation_easing) both;
71
+ @starting-style {
72
+ .drawer[open] {
73
+ translate: 100% 0;
74
+ }
75
+
76
+ :host([side='left']) .drawer[open] {
77
+ translate: -100% 0;
78
+ }
105
79
  }
106
80
 
107
- :host([side='left']) .drawer[open] .panel {
108
- animation: bds-drawer-slide-left var(--bds-animation_transition-duration) var(--bds-animation_easing) both;
81
+ .drawer::backdrop {
82
+ background-color: var(--color_backdrop, rgb(0 0 0 / 50%));
83
+ backdrop-filter: blur(3px);
84
+ opacity: 0;
85
+ transition:
86
+ opacity var(--bds-animation_transition-duration) var(--bds-animation_easing),
87
+ display var(--bds-animation_transition-duration) allow-discrete,
88
+ overlay var(--bds-animation_transition-duration) allow-discrete;
109
89
  }
110
90
 
111
- @keyframes bds-drawer-slide-right {
112
- from { translate: 100% 0; }
113
- to { translate: 0 0; }
91
+ .drawer[open]::backdrop {
92
+ opacity: 1;
114
93
  }
115
94
 
116
- @keyframes bds-drawer-slide-left {
117
- from { translate: -100% 0; }
118
- to { translate: 0 0; }
95
+ @starting-style {
96
+ .drawer[open]::backdrop {
97
+ opacity: 0;
98
+ }
119
99
  }
120
100
 
121
101
  .header {
@@ -170,16 +150,9 @@ export class BdsDrawer extends LitElement {
170
150
 
171
151
  @media (prefers-reduced-motion: reduce) {
172
152
  .drawer,
173
- .drawer::backdrop,
174
- .panel {
153
+ .drawer::backdrop {
175
154
  transition: none;
176
155
  }
177
-
178
- :host([side='right']) .drawer[open] .panel,
179
- :host(:not([side])) .drawer[open] .panel,
180
- :host([side='left']) .drawer[open] .panel {
181
- animation: none;
182
- }
183
156
  }
184
157
  `;
185
158
 
@@ -251,7 +224,13 @@ export class BdsDrawer extends LitElement {
251
224
  }
252
225
 
253
226
  private _handleBackdropClick(e: MouseEvent) {
254
- if (e.target === this._dialog) this._closeDrawer();
227
+ const dialog = this._dialog;
228
+ if (!dialog) return;
229
+ const rect = dialog.getBoundingClientRect();
230
+ const outside =
231
+ e.clientX < rect.left || e.clientX > rect.right ||
232
+ e.clientY < rect.top || e.clientY > rect.bottom;
233
+ if (outside) this._closeDrawer();
255
234
  }
256
235
 
257
236
  private _handleCancel(e: Event) {
@@ -283,23 +262,21 @@ export class BdsDrawer extends LitElement {
283
262
  @cancel=${this._handleCancel}
284
263
  @keydown=${this._handleKeydown}
285
264
  >
286
- <div class="panel">
287
- <div class="header">
288
- <span class="title"><slot name="title"></slot></span>
289
- <button
290
- type="button"
291
- class="close-button"
292
- aria-label="Close drawer"
293
- @click=${this._closeDrawer}
294
- >
295
- <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
296
- <path stroke-linecap="round" stroke-linejoin="round" d="M18 6L6 18M6 6l12 12" />
297
- </svg>
298
- </button>
299
- </div>
300
- <div class="body">
301
- <slot></slot>
302
- </div>
265
+ <div class="header">
266
+ <span class="title"><slot name="title"></slot></span>
267
+ <button
268
+ type="button"
269
+ class="close-button"
270
+ aria-label="Close drawer"
271
+ @click=${this._closeDrawer}
272
+ >
273
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
274
+ <path stroke-linecap="round" stroke-linejoin="round" d="M18 6L6 18M6 6l12 12" />
275
+ </svg>
276
+ </button>
277
+ </div>
278
+ <div class="body">
279
+ <slot></slot>
303
280
  </div>
304
281
  </dialog>
305
282
  `;
@@ -0,0 +1,124 @@
1
+ import { LitElement, css, html } from 'lit';
2
+
3
+ /**
4
+ * `<bds-dropdown-menu-item>` — a single item inside a `<bds-dropdown-menu>`.
5
+ *
6
+ * Attributes:
7
+ * value — value associated with this item
8
+ * disabled — disables this item
9
+ * destructive — renders the item in a destructive (red) style
10
+ *
11
+ * Slots:
12
+ * (default) — item label text
13
+ *
14
+ * Events:
15
+ * bds-select — fired when the item is clicked or activated; `detail: { value: string }`
16
+ *
17
+ * @example
18
+ * <bds-dropdown-menu-item value="delete" destructive>Delete</bds-dropdown-menu-item>
19
+ */
20
+ export class BdsDropdownMenuItem extends LitElement {
21
+ static styles = css`
22
+ :host {
23
+ display: block;
24
+ }
25
+
26
+ :host([disabled]) {
27
+ opacity: 0.5;
28
+ pointer-events: none;
29
+ }
30
+
31
+ .item {
32
+ display: flex;
33
+ align-items: center;
34
+ gap: var(--bds-space_xs);
35
+ inline-size: 100%;
36
+ padding: var(--bds-space_xs) var(--bds-space_s);
37
+ font-size: var(--bds-font_size--body);
38
+ font-family: var(--bds-font_family--body);
39
+ color: var(--bds-color_on-bg);
40
+ background: none;
41
+ border: none;
42
+ text-align: start;
43
+ cursor: pointer;
44
+ border-radius: var(--bds-border_radius--xs);
45
+ transition: var(--bds-animation_transition);
46
+ }
47
+
48
+ :host([destructive]) .item {
49
+ color: var(--bds-color_error);
50
+ }
51
+
52
+ @media (hover: hover) and (pointer: fine) {
53
+ .item:hover {
54
+ background-color: var(--bds-color_bg--subtle);
55
+ }
56
+
57
+ :host([destructive]) .item:hover {
58
+ background-color: color-mix(in srgb, var(--bds-color_error) 10%, transparent);
59
+ }
60
+ }
61
+
62
+ .item:focus-visible {
63
+ outline: var(--bds-outline_default);
64
+ outline-offset: var(--bds-outline_offset);
65
+ }
66
+ `;
67
+
68
+ static properties = {
69
+ value: { type: String, reflect: true },
70
+ disabled: { type: Boolean, reflect: true },
71
+ destructive: { type: Boolean, reflect: true },
72
+ };
73
+
74
+ declare value: string;
75
+ declare disabled: boolean;
76
+ declare destructive: boolean;
77
+
78
+ constructor() {
79
+ super();
80
+ this.value = '';
81
+ this.disabled = false;
82
+ this.destructive = false;
83
+ }
84
+
85
+ private _handleClick() {
86
+ if (this.disabled) return;
87
+ this.dispatchEvent(new CustomEvent('bds-select', {
88
+ detail: { value: this.value },
89
+ bubbles: true,
90
+ composed: true,
91
+ }));
92
+ }
93
+
94
+ private _handleKeydown(e: KeyboardEvent) {
95
+ if (e.key === 'Enter' || e.key === ' ') {
96
+ e.preventDefault();
97
+ this._handleClick();
98
+ }
99
+ }
100
+
101
+ render() {
102
+ return html`
103
+ <button
104
+ class="item"
105
+ type="button"
106
+ role="menuitem"
107
+ tabindex=${this.disabled ? '-1' : '0'}
108
+ ?disabled=${this.disabled}
109
+ @click=${this._handleClick}
110
+ @keydown=${this._handleKeydown}
111
+ >
112
+ <slot></slot>
113
+ </button>
114
+ `;
115
+ }
116
+ }
117
+
118
+ customElements.define('bds-dropdown-menu-item', BdsDropdownMenuItem);
119
+
120
+ declare global {
121
+ interface HTMLElementTagNameMap {
122
+ 'bds-dropdown-menu-item': BdsDropdownMenuItem;
123
+ }
124
+ }
@@ -0,0 +1,102 @@
1
+ import { fixture, cleanup } from '../test/helpers';
2
+ import { BdsDropdownMenu } from './bds-dropdown-menu';
3
+ import { BdsDropdownMenuItem } from './bds-dropdown-menu-item';
4
+
5
+ describe('bds-dropdown-menu', () => {
6
+ it('is registered as a custom element', () => {
7
+ expect(customElements.get('bds-dropdown-menu')).toBe(BdsDropdownMenu);
8
+ });
9
+
10
+ it('is closed by default', async () => {
11
+ const el = await fixture('<bds-dropdown-menu></bds-dropdown-menu>') as BdsDropdownMenu;
12
+ expect(el.open).toBe(false);
13
+ cleanup(el);
14
+ });
15
+
16
+ it('open attribute is absent when closed', async () => {
17
+ const el = await fixture('<bds-dropdown-menu></bds-dropdown-menu>') as BdsDropdownMenu;
18
+ expect(el.open).toBe(false);
19
+ expect(el.hasAttribute('open')).toBe(false);
20
+ cleanup(el);
21
+ });
22
+
23
+ it('fires bds-open when _open() is called', async () => {
24
+ const el = await fixture('<bds-dropdown-menu></bds-dropdown-menu>') as BdsDropdownMenu;
25
+ const events: string[] = [];
26
+ el.addEventListener('bds-open', () => events.push('open'));
27
+ el._open();
28
+ expect(events).toContain('open');
29
+ cleanup(el);
30
+ });
31
+
32
+ it('fires bds-close when _close() is called', async () => {
33
+ const el = await fixture('<bds-dropdown-menu></bds-dropdown-menu>') as BdsDropdownMenu;
34
+ el._open();
35
+ const events: string[] = [];
36
+ el.addEventListener('bds-close', () => events.push('close'));
37
+ el._close();
38
+ expect(events).toContain('close');
39
+ cleanup(el);
40
+ });
41
+
42
+ it('does not fire bds-open if already open (state guard)', async () => {
43
+ const el = await fixture('<bds-dropdown-menu></bds-dropdown-menu>') as BdsDropdownMenu;
44
+ el._open();
45
+ const events: string[] = [];
46
+ el.addEventListener('bds-open', () => events.push('open'));
47
+ el._open(); // call again
48
+ expect(events.length).toBe(0);
49
+ cleanup(el);
50
+ });
51
+
52
+ it('does not fire bds-close if already closed (state guard)', async () => {
53
+ const el = await fixture('<bds-dropdown-menu></bds-dropdown-menu>') as BdsDropdownMenu;
54
+ const events: string[] = [];
55
+ el.addEventListener('bds-close', () => events.push('close'));
56
+ el._close(); // already closed
57
+ expect(events.length).toBe(0);
58
+ cleanup(el);
59
+ });
60
+
61
+ it('closes on Escape key', async () => {
62
+ const el = await fixture('<bds-dropdown-menu></bds-dropdown-menu>') as BdsDropdownMenu;
63
+ el._open();
64
+ expect(el.open).toBe(true);
65
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
66
+ expect(el.open).toBe(false);
67
+ cleanup(el);
68
+ });
69
+ });
70
+
71
+ describe('bds-dropdown-menu-item', () => {
72
+ it('is registered as a custom element', () => {
73
+ expect(customElements.get('bds-dropdown-menu-item')).toBe(BdsDropdownMenuItem);
74
+ });
75
+
76
+ it('renders a button with role="menuitem"', async () => {
77
+ const el = await fixture('<bds-dropdown-menu-item value="edit">Edit</bds-dropdown-menu-item>');
78
+ const btn = el.shadowRoot!.querySelector('button')!;
79
+ expect(btn.getAttribute('role')).toBe('menuitem');
80
+ cleanup(el);
81
+ });
82
+
83
+ it('fires bds-select on click', async () => {
84
+ const el = await fixture('<bds-dropdown-menu-item value="edit">Edit</bds-dropdown-menu-item>');
85
+ const events: string[] = [];
86
+ el.addEventListener('bds-select', (e: Event) => {
87
+ events.push((e as CustomEvent).detail.value);
88
+ });
89
+ el.shadowRoot!.querySelector<HTMLButtonElement>('button')!.click();
90
+ expect(events).toEqual(['edit']);
91
+ cleanup(el);
92
+ });
93
+
94
+ it('does not fire bds-select when disabled', async () => {
95
+ const el = await fixture('<bds-dropdown-menu-item value="edit" disabled>Edit</bds-dropdown-menu-item>');
96
+ const events: string[] = [];
97
+ el.addEventListener('bds-select', () => events.push('selected'));
98
+ el.shadowRoot!.querySelector<HTMLButtonElement>('button')!.click();
99
+ expect(events.length).toBe(0);
100
+ cleanup(el);
101
+ });
102
+ });
@@ -0,0 +1,200 @@
1
+ import { LitElement, css, html } from 'lit';
2
+ import type { BdsDropdownMenuItem } from './bds-dropdown-menu-item';
3
+
4
+ export type DropdownMenuPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
5
+
6
+ /**
7
+ * `<bds-dropdown-menu>` — anchored dropdown menu with keyboard navigation.
8
+ *
9
+ * Uses the native Popover API for the floating panel (light-dismiss, top-layer).
10
+ * The trigger slot element controls open/close. The component handles keyboard
11
+ * navigation (Arrow keys, Escape, Tab) and focus management.
12
+ *
13
+ * Attributes:
14
+ * open — controls open/closed state (reflected)
15
+ * placement — menu position relative to trigger (default: `"bottom-start"`)
16
+ *
17
+ * Slots:
18
+ * trigger — the activating element (button or similar)
19
+ * (default) — `<bds-dropdown-menu-item>` elements
20
+ *
21
+ * Events:
22
+ * bds-open — fired when the menu opens
23
+ * bds-close — fired when the menu closes
24
+ *
25
+ * @example
26
+ * <bds-dropdown-menu>
27
+ * <button slot="trigger">Options</button>
28
+ * <bds-dropdown-menu-item value="edit">Edit</bds-dropdown-menu-item>
29
+ * <bds-dropdown-menu-item value="delete" destructive>Delete</bds-dropdown-menu-item>
30
+ * </bds-dropdown-menu>
31
+ */
32
+ export class BdsDropdownMenu extends LitElement {
33
+ static styles = css`
34
+ :host {
35
+ display: inline-block;
36
+ position: relative;
37
+ }
38
+
39
+ .panel {
40
+ position: absolute;
41
+ inset-block-start: calc(100% + var(--bds-space_xxs));
42
+ inset-inline-start: 0;
43
+ min-inline-size: 10rem;
44
+ background-color: var(--bds-color_bg);
45
+ border-radius: var(--bds-border_radius--s);
46
+ box-shadow: var(--bds-shadow_m);
47
+ padding: var(--bds-space_xxs);
48
+ z-index: var(--bds-z-index_dropdown, 200);
49
+ display: none;
50
+ flex-direction: column;
51
+ }
52
+
53
+ :host([placement='bottom-end']) .panel {
54
+ inset-inline-start: auto;
55
+ inset-inline-end: 0;
56
+ }
57
+
58
+ :host([placement='top-start']) .panel {
59
+ inset-block-start: auto;
60
+ inset-block-end: calc(100% + var(--bds-space_xxs));
61
+ inset-inline-start: 0;
62
+ }
63
+
64
+ :host([placement='top-end']) .panel {
65
+ inset-block-start: auto;
66
+ inset-block-end: calc(100% + var(--bds-space_xxs));
67
+ inset-inline-start: auto;
68
+ inset-inline-end: 0;
69
+ }
70
+
71
+ :host([open]) .panel {
72
+ display: flex;
73
+ }
74
+ `;
75
+
76
+ static properties = {
77
+ open: { type: Boolean, reflect: true },
78
+ placement: { type: String, reflect: true },
79
+ };
80
+
81
+ declare open: boolean;
82
+ declare placement: DropdownMenuPlacement;
83
+
84
+ constructor() {
85
+ super();
86
+ this.open = false;
87
+ this.placement = 'bottom-start';
88
+ }
89
+
90
+ connectedCallback() {
91
+ super.connectedCallback();
92
+ document.addEventListener('click', this._handleOutsideClick);
93
+ this.addEventListener('keydown', this._handleKeydown);
94
+ this.addEventListener('bds-select', this._handleItemSelect);
95
+ }
96
+
97
+ disconnectedCallback() {
98
+ super.disconnectedCallback();
99
+ document.removeEventListener('click', this._handleOutsideClick);
100
+ this.removeEventListener('keydown', this._handleKeydown);
101
+ this.removeEventListener('bds-select', this._handleItemSelect);
102
+ }
103
+
104
+ private get _items(): BdsDropdownMenuItem[] {
105
+ return [...this.querySelectorAll<BdsDropdownMenuItem>('bds-dropdown-menu-item')].filter(
106
+ item => !item.disabled,
107
+ );
108
+ }
109
+
110
+ private get _trigger(): HTMLElement | null {
111
+ const slot = this.shadowRoot?.querySelector<HTMLSlotElement>('slot[name="trigger"]');
112
+ return (slot?.assignedElements()[0] as HTMLElement) ?? null;
113
+ }
114
+
115
+ _open() {
116
+ if (this.open) return;
117
+ this.open = true;
118
+ this.dispatchEvent(new CustomEvent('bds-open', { bubbles: true, composed: true }));
119
+ // Focus first item after render
120
+ this.updateComplete.then(() => {
121
+ this._items[0]?.shadowRoot?.querySelector<HTMLElement>('button')?.focus();
122
+ });
123
+ }
124
+
125
+ _close() {
126
+ if (!this.open) return;
127
+ this.open = false;
128
+ this.dispatchEvent(new CustomEvent('bds-close', { bubbles: true, composed: true }));
129
+ this._trigger?.focus();
130
+ }
131
+
132
+ private _handleTriggerClick = (e: Event) => {
133
+ e.stopPropagation();
134
+ if (this.open) { this._close(); } else { this._open(); }
135
+ };
136
+
137
+ private _handleOutsideClick = (e: Event) => {
138
+ if (this.open && !this.contains(e.target as Node)) {
139
+ this._close();
140
+ }
141
+ };
142
+
143
+ private _handleKeydown = (e: KeyboardEvent) => {
144
+ if (!this.open) return;
145
+
146
+ const items = this._items;
147
+ const focused = items.findIndex(item =>
148
+ item.shadowRoot?.activeElement !== null &&
149
+ item.shadowRoot?.querySelector('button') === item.shadowRoot?.activeElement,
150
+ );
151
+
152
+ if (e.key === 'Escape') {
153
+ e.preventDefault();
154
+ this._close();
155
+ } else if (e.key === 'ArrowDown') {
156
+ e.preventDefault();
157
+ const next = (focused + 1) % items.length;
158
+ items[next]?.shadowRoot?.querySelector<HTMLElement>('button')?.focus();
159
+ } else if (e.key === 'ArrowUp') {
160
+ e.preventDefault();
161
+ const prev = (focused - 1 + items.length) % items.length;
162
+ items[prev]?.shadowRoot?.querySelector<HTMLElement>('button')?.focus();
163
+ } else if (e.key === 'Tab') {
164
+ this._close();
165
+ }
166
+ };
167
+
168
+ private _handleItemSelect = () => {
169
+ this._close();
170
+ };
171
+
172
+ private _onTriggerSlotChange() {
173
+ const trigger = this._trigger;
174
+ if (trigger) {
175
+ trigger.removeEventListener('click', this._handleTriggerClick);
176
+ trigger.addEventListener('click', this._handleTriggerClick);
177
+ }
178
+ }
179
+
180
+ render() {
181
+ return html`
182
+ <slot name="trigger" @slotchange=${this._onTriggerSlotChange}></slot>
183
+ <div
184
+ class="panel"
185
+ role="menu"
186
+ aria-hidden=${!this.open ? 'true' : 'false'}
187
+ >
188
+ <slot></slot>
189
+ </div>
190
+ `;
191
+ }
192
+ }
193
+
194
+ customElements.define('bds-dropdown-menu', BdsDropdownMenu);
195
+
196
+ declare global {
197
+ interface HTMLElementTagNameMap {
198
+ 'bds-dropdown-menu': BdsDropdownMenu;
199
+ }
200
+ }