@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
@@ -0,0 +1,296 @@
1
+ import { LitElement, css, html } from 'lit';
2
+
3
+ /**
4
+ * `<bds-carousel>` — scroll-snap based carousel/slider.
5
+ *
6
+ * Attributes:
7
+ * current-index — active slide index (0-based, default: 0)
8
+ * indicators — show dot indicators
9
+ * loop — wrap from last slide back to first and vice versa
10
+ *
11
+ * Slots:
12
+ * (default) — slide content elements (each direct child = one slide)
13
+ * prev — custom previous button (renders a default button if omitted)
14
+ * next — custom next button (renders a default button if omitted)
15
+ *
16
+ * Events:
17
+ * bds-slide-change — fired when active slide changes; `detail: { index: number }`
18
+ *
19
+ * @example
20
+ * <bds-carousel indicators loop>
21
+ * <div>Slide 1</div>
22
+ * <div>Slide 2</div>
23
+ * <div>Slide 3</div>
24
+ * </bds-carousel>
25
+ */
26
+ export class BdsCarousel extends LitElement {
27
+ static styles = css`
28
+ :host {
29
+ display: block;
30
+ position: relative;
31
+ }
32
+
33
+ .track {
34
+ display: flex;
35
+ overflow-inline: auto;
36
+ scroll-snap-type: inline mandatory;
37
+ scroll-behavior: smooth;
38
+ scrollbar-width: none;
39
+ -ms-overflow-style: none;
40
+ }
41
+
42
+ .track::-webkit-scrollbar {
43
+ display: none;
44
+ }
45
+
46
+ ::slotted(*) {
47
+ flex-shrink: 0;
48
+ inline-size: 100%;
49
+ scroll-snap-align: start;
50
+ }
51
+
52
+ .controls {
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: space-between;
56
+ margin-block-start: var(--bds-space_s);
57
+ }
58
+
59
+ .nav-btn {
60
+ display: inline-flex;
61
+ align-items: center;
62
+ justify-content: center;
63
+ inline-size: 2.5rem;
64
+ block-size: 2.5rem;
65
+ border: none;
66
+ border-radius: var(--bds-border_radius--full, 9999px);
67
+ background-color: var(--bds-color_bg--subtle);
68
+ color: var(--bds-color_on-bg);
69
+ cursor: pointer;
70
+ transition: var(--bds-animation_transition);
71
+ }
72
+
73
+ .nav-btn:focus-visible {
74
+ outline: var(--bds-outline_default);
75
+ outline-offset: var(--bds-outline_offset);
76
+ }
77
+
78
+ @media (hover: hover) and (pointer: fine) {
79
+ .nav-btn:hover {
80
+ background-color: var(--bds-color_bg--strong);
81
+ }
82
+ }
83
+
84
+ .nav-btn:disabled {
85
+ opacity: 0.4;
86
+ cursor: not-allowed;
87
+ }
88
+
89
+ .nav-btn svg {
90
+ inline-size: 1.25rem;
91
+ block-size: 1.25rem;
92
+ }
93
+
94
+ .indicators {
95
+ display: flex;
96
+ gap: var(--bds-space_xxs);
97
+ align-items: center;
98
+ justify-content: center;
99
+ flex: 1;
100
+ }
101
+
102
+ .dot {
103
+ inline-size: 0.5rem;
104
+ block-size: 0.5rem;
105
+ border-radius: 50%;
106
+ border: none;
107
+ padding: 0;
108
+ background-color: var(--bds-color_bg--strong);
109
+ cursor: pointer;
110
+ transition: var(--bds-animation_transition);
111
+ }
112
+
113
+ .dot.--active {
114
+ background-color: var(--bds-color_interactive);
115
+ inline-size: 1rem;
116
+ border-radius: 0.25rem;
117
+ }
118
+
119
+ .dot:focus-visible {
120
+ outline: var(--bds-outline_default);
121
+ outline-offset: var(--bds-outline_offset);
122
+ }
123
+
124
+ @media (prefers-reduced-motion: reduce) {
125
+ .track {
126
+ scroll-behavior: auto;
127
+ }
128
+ }
129
+ `;
130
+
131
+ static properties = {
132
+ currentIndex: { type: Number, attribute: 'current-index', reflect: true },
133
+ indicators: { type: Boolean, reflect: true },
134
+ loop: { type: Boolean, reflect: true },
135
+ };
136
+
137
+ declare currentIndex: number;
138
+ declare indicators: boolean;
139
+ declare loop: boolean;
140
+
141
+ private _slideCount = 0;
142
+ private _io: IntersectionObserver | null = null;
143
+
144
+ constructor() {
145
+ super();
146
+ this.currentIndex = 0;
147
+ this.indicators = false;
148
+ this.loop = false;
149
+ }
150
+
151
+ private get _track(): HTMLElement | null {
152
+ return this.shadowRoot?.querySelector('.track') ?? null;
153
+ }
154
+
155
+ connectedCallback() {
156
+ super.connectedCallback();
157
+ // Count slides from light DOM children
158
+ this._updateSlideCount();
159
+ }
160
+
161
+ disconnectedCallback() {
162
+ super.disconnectedCallback();
163
+ this._io?.disconnect();
164
+ this._io = null;
165
+ }
166
+
167
+ private _updateSlideCount() {
168
+ this._slideCount = this.children.length;
169
+ this.requestUpdate();
170
+ }
171
+
172
+ private _goTo(index: number) {
173
+ const count = this._slideCount;
174
+ if (count === 0) return;
175
+
176
+ let next = index;
177
+ if (this.loop) {
178
+ next = ((index % count) + count) % count;
179
+ } else {
180
+ next = Math.max(0, Math.min(index, count - 1));
181
+ }
182
+
183
+ if (next === this.currentIndex && index === next) return;
184
+
185
+ this.currentIndex = next;
186
+
187
+ const track = this._track;
188
+ const slides = track?.querySelector('slot')?.assignedElements() ?? [];
189
+ const target = slides[next] as HTMLElement | undefined;
190
+ if (target && typeof target.scrollIntoView === 'function') {
191
+ target.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });
192
+ }
193
+
194
+ this.dispatchEvent(new CustomEvent('bds-slide-change', {
195
+ detail: { index: next },
196
+ bubbles: true,
197
+ composed: true,
198
+ }));
199
+ }
200
+
201
+ private _onSlotChange() {
202
+ this._updateSlideCount();
203
+ // Set up IntersectionObserver on the default slot's items
204
+ this._io?.disconnect();
205
+ const track = this._track;
206
+ if (!track) return;
207
+
208
+ const slot = track.querySelector('slot') as HTMLSlotElement | null;
209
+ const slides = slot?.assignedElements() ?? [];
210
+
211
+ if (typeof IntersectionObserver === 'undefined') return;
212
+
213
+ this._io = new IntersectionObserver(
214
+ entries => {
215
+ const visible = entries.find(e => e.isIntersecting && e.intersectionRatio >= 0.5);
216
+ if (visible) {
217
+ const idx = slides.indexOf(visible.target as Element);
218
+ if (idx !== -1 && idx !== this.currentIndex) {
219
+ this.currentIndex = idx;
220
+ this.requestUpdate();
221
+ }
222
+ }
223
+ },
224
+ { root: track, threshold: 0.5 },
225
+ );
226
+
227
+ slides.forEach(slide => this._io!.observe(slide));
228
+ }
229
+
230
+ render() {
231
+ const atStart = this.currentIndex <= 0;
232
+ const atEnd = this.currentIndex >= this._slideCount - 1;
233
+ const showPrev = this.loop || !atStart;
234
+ const showNext = this.loop || !atEnd;
235
+
236
+ return html`
237
+ <div class="track" aria-label="Carousel" role="region">
238
+ <slot @slotchange=${this._onSlotChange}></slot>
239
+ </div>
240
+ <div class="controls">
241
+ <slot name="prev">
242
+ <button
243
+ class="nav-btn"
244
+ type="button"
245
+ aria-label="Previous slide"
246
+ ?disabled=${!showPrev}
247
+ @click=${() => this._goTo(this.currentIndex - 1)}
248
+ >
249
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
250
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
251
+ </svg>
252
+ </button>
253
+ </slot>
254
+
255
+ ${this.indicators
256
+ ? html`
257
+ <div class="indicators" role="tablist" aria-label="Slides">
258
+ ${Array.from({ length: this._slideCount }, (_, i) => html`
259
+ <button
260
+ class="dot ${i === this.currentIndex ? '--active' : ''}"
261
+ type="button"
262
+ role="tab"
263
+ aria-selected=${i === this.currentIndex ? 'true' : 'false'}
264
+ aria-label="Slide ${i + 1}"
265
+ @click=${() => this._goTo(i)}
266
+ ></button>
267
+ `)}
268
+ </div>
269
+ `
270
+ : html`<span style="flex:1"></span>`}
271
+
272
+ <slot name="next">
273
+ <button
274
+ class="nav-btn"
275
+ type="button"
276
+ aria-label="Next slide"
277
+ ?disabled=${!showNext}
278
+ @click=${() => this._goTo(this.currentIndex + 1)}
279
+ >
280
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
281
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
282
+ </svg>
283
+ </button>
284
+ </slot>
285
+ </div>
286
+ `;
287
+ }
288
+ }
289
+
290
+ customElements.define('bds-carousel', BdsCarousel);
291
+
292
+ declare global {
293
+ interface HTMLElementTagNameMap {
294
+ 'bds-carousel': BdsCarousel;
295
+ }
296
+ }
@@ -0,0 +1,67 @@
1
+ import { fixture, cleanup } from '../test/helpers';
2
+ import { BdsPagination } from './bds-pagination';
3
+
4
+ describe('bds-pagination', () => {
5
+ it('is registered as a custom element', () => {
6
+ expect(customElements.get('bds-pagination')).toBe(BdsPagination);
7
+ });
8
+
9
+ it('renders a nav element', async () => {
10
+ const el = await fixture('<bds-pagination total-pages="5"></bds-pagination>');
11
+ expect(el.shadowRoot!.querySelector('nav')).not.toBeNull();
12
+ cleanup(el);
13
+ });
14
+
15
+ it('defaults current-page to 1', async () => {
16
+ const el = await fixture('<bds-pagination total-pages="5"></bds-pagination>') as BdsPagination;
17
+ expect(el.currentPage).toBe(1);
18
+ cleanup(el);
19
+ });
20
+
21
+ it('renders page buttons for small page count', async () => {
22
+ const el = await fixture('<bds-pagination current-page="1" total-pages="3"></bds-pagination>');
23
+ const buttons = el.shadowRoot!.querySelectorAll('.button:not(.--nav)');
24
+ expect(buttons.length).toBe(3);
25
+ cleanup(el);
26
+ });
27
+
28
+ it('marks active page with aria-current="page"', async () => {
29
+ const el = await fixture('<bds-pagination current-page="2" total-pages="5"></bds-pagination>');
30
+ const active = el.shadowRoot!.querySelector('[aria-current="page"]');
31
+ expect(active?.textContent?.trim()).toBe('2');
32
+ cleanup(el);
33
+ });
34
+
35
+ it('disables prev button on first page', async () => {
36
+ const el = await fixture('<bds-pagination current-page="1" total-pages="5"></bds-pagination>');
37
+ const prev = el.shadowRoot!.querySelector('[aria-label="Previous page"]') as HTMLButtonElement;
38
+ expect(prev.disabled).toBe(true);
39
+ cleanup(el);
40
+ });
41
+
42
+ it('disables next button on last page', async () => {
43
+ const el = await fixture('<bds-pagination current-page="5" total-pages="5"></bds-pagination>');
44
+ const next = el.shadowRoot!.querySelector('[aria-label="Next page"]') as HTMLButtonElement;
45
+ expect(next.disabled).toBe(true);
46
+ cleanup(el);
47
+ });
48
+
49
+ it('fires bds-page-change event on page click', async () => {
50
+ const el = await fixture('<bds-pagination current-page="1" total-pages="5"></bds-pagination>');
51
+ const pages: number[] = [];
52
+ el.addEventListener('bds-page-change', (e: Event) => {
53
+ pages.push((e as CustomEvent).detail.page);
54
+ });
55
+ const buttons = el.shadowRoot!.querySelectorAll<HTMLButtonElement>('.button:not(.--nav)');
56
+ buttons[1].click(); // click page 2
57
+ expect(pages).toEqual([2]);
58
+ cleanup(el);
59
+ });
60
+
61
+ it('renders ellipsis for large page counts', async () => {
62
+ const el = await fixture('<bds-pagination current-page="5" total-pages="20"></bds-pagination>');
63
+ const ellipsis = el.shadowRoot!.querySelectorAll('.ellipsis');
64
+ expect(ellipsis.length).toBeGreaterThan(0);
65
+ cleanup(el);
66
+ });
67
+ });
@@ -0,0 +1,197 @@
1
+ import { LitElement, css, html } from 'lit';
2
+
3
+ function getPageRange(current: number, total: number): (number | '...')[] {
4
+ const delta = 1;
5
+ const range: (number | '...')[] = [];
6
+ for (let i = 1; i <= total; i++) {
7
+ if (i === 1 || i === total || (i >= current - delta && i <= current + delta)) {
8
+ range.push(i);
9
+ } else if (range[range.length - 1] !== '...') {
10
+ range.push('...');
11
+ }
12
+ }
13
+ return range;
14
+ }
15
+
16
+ /**
17
+ * `<bds-pagination>` — pagination navigation control.
18
+ *
19
+ * Attributes:
20
+ * current-page — active page number (1-based, default: 1)
21
+ * total-pages — total number of pages (default: 1)
22
+ *
23
+ * Events:
24
+ * bds-page-change — fired when the user navigates; `detail: { page: number }`
25
+ *
26
+ * @example
27
+ * <bds-pagination current-page="3" total-pages="10"></bds-pagination>
28
+ * <script>
29
+ * document.querySelector('bds-pagination')
30
+ * .addEventListener('bds-page-change', e => console.log(e.detail.page));
31
+ * </script>
32
+ */
33
+ export class BdsPagination extends LitElement {
34
+ static styles = css`
35
+ :host {
36
+ display: block;
37
+ }
38
+
39
+ .pagination {
40
+ display: flex;
41
+ align-items: center;
42
+ }
43
+
44
+ .list {
45
+ display: flex;
46
+ align-items: center;
47
+ gap: var(--bds-space_xxs);
48
+ list-style: none;
49
+ margin: 0;
50
+ padding: 0;
51
+ }
52
+
53
+ .button {
54
+ display: inline-flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+ min-inline-size: 2rem;
58
+ block-size: 2rem;
59
+ padding-inline: var(--bds-space_xs);
60
+ font-size: var(--bds-font_size--body--s);
61
+ font-family: var(--bds-font_family--body);
62
+ border: none;
63
+ background: none;
64
+ border-radius: var(--bds-border_radius--xs);
65
+ cursor: pointer;
66
+ color: var(--bds-color_on-bg);
67
+ transition: var(--bds-animation_transition);
68
+ --bdc_color: transparent;
69
+ --bdc-outline_radius: var(--bds-border_radius--xs);
70
+ box-shadow: inset 0 0 0 var(--bdc-outline_width) var(--bdc_color), var(--bdc-outline_shadow);
71
+ }
72
+
73
+ .button:focus-visible {
74
+ outline: var(--bds-outline_default);
75
+ outline-offset: var(--bds-outline_offset);
76
+ }
77
+
78
+ @media (hover: hover) and (pointer: fine) {
79
+ .button:not(:disabled):not(.--active):hover {
80
+ background-color: var(--bds-color_bg--subtle);
81
+ }
82
+ }
83
+
84
+ .button.--active {
85
+ background-color: var(--bds-color_interactive);
86
+ color: var(--bds-color_on-interactive);
87
+ font-weight: var(--bds-font_weight--semibold);
88
+ }
89
+
90
+ .button.--nav {
91
+ color: var(--bds-color_on-bg--subtle);
92
+ }
93
+
94
+ .button:disabled {
95
+ opacity: 0.4;
96
+ cursor: not-allowed;
97
+ }
98
+
99
+ .ellipsis {
100
+ display: inline-flex;
101
+ align-items: center;
102
+ justify-content: center;
103
+ min-inline-size: 2rem;
104
+ color: var(--bds-color_on-bg--subtle);
105
+ font-size: var(--bds-font_size--body--s);
106
+ user-select: none;
107
+ }
108
+
109
+ .nav-icon {
110
+ inline-size: 1rem;
111
+ block-size: 1rem;
112
+ }
113
+ `;
114
+
115
+ static properties = {
116
+ currentPage: { type: Number, attribute: 'current-page', reflect: true },
117
+ totalPages: { type: Number, attribute: 'total-pages', reflect: true },
118
+ };
119
+
120
+ declare currentPage: number;
121
+ declare totalPages: number;
122
+
123
+ constructor() {
124
+ super();
125
+ this.currentPage = 1;
126
+ this.totalPages = 1;
127
+ }
128
+
129
+ private _navigate(page: number) {
130
+ if (page < 1 || page > this.totalPages || page === this.currentPage) return;
131
+ this.currentPage = page;
132
+ this.dispatchEvent(new CustomEvent('bds-page-change', {
133
+ detail: { page },
134
+ bubbles: true,
135
+ composed: true,
136
+ }));
137
+ }
138
+
139
+ render() {
140
+ const pages = getPageRange(this.currentPage, this.totalPages);
141
+
142
+ return html`
143
+ <nav class="pagination" aria-label="Pagination">
144
+ <ul class="list">
145
+ <li>
146
+ <button
147
+ type="button"
148
+ class="button --nav"
149
+ ?disabled=${this.currentPage <= 1}
150
+ aria-label="Previous page"
151
+ @click=${() => this._navigate(this.currentPage - 1)}
152
+ >
153
+ <svg class="nav-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
154
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
155
+ </svg>
156
+ </button>
157
+ </li>
158
+ ${pages.map(page =>
159
+ page === '...'
160
+ ? html`<li><span class="ellipsis" aria-hidden="true">…</span></li>`
161
+ : html`
162
+ <li>
163
+ <button
164
+ type="button"
165
+ class="button ${page === this.currentPage ? '--active' : ''}"
166
+ aria-current=${page === this.currentPage ? 'page' : 'false'}
167
+ @click=${() => this._navigate(page as number)}
168
+ >${page}</button>
169
+ </li>
170
+ `
171
+ )}
172
+ <li>
173
+ <button
174
+ type="button"
175
+ class="button --nav"
176
+ ?disabled=${this.currentPage >= this.totalPages}
177
+ aria-label="Next page"
178
+ @click=${() => this._navigate(this.currentPage + 1)}
179
+ >
180
+ <svg class="nav-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
181
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
182
+ </svg>
183
+ </button>
184
+ </li>
185
+ </ul>
186
+ </nav>
187
+ `;
188
+ }
189
+ }
190
+
191
+ customElements.define('bds-pagination', BdsPagination);
192
+
193
+ declare global {
194
+ interface HTMLElementTagNameMap {
195
+ 'bds-pagination': BdsPagination;
196
+ }
197
+ }
@@ -0,0 +1,45 @@
1
+ import { fixture, cleanup } from '../test/helpers';
2
+ import { BdsTable } from './bds-table';
3
+
4
+ describe('bds-table', () => {
5
+ it('is registered as a custom element', () => {
6
+ expect(customElements.get('bds-table')).toBe(BdsTable);
7
+ });
8
+
9
+ it('renders a wrapper div', async () => {
10
+ const el = await fixture('<bds-table></bds-table>');
11
+ expect(el.shadowRoot!.querySelector('.wrapper')).not.toBeNull();
12
+ cleanup(el);
13
+ });
14
+
15
+ it('renders a slot for table content', async () => {
16
+ const el = await fixture('<bds-table></bds-table>');
17
+ expect(el.shadowRoot!.querySelector('slot')).not.toBeNull();
18
+ cleanup(el);
19
+ });
20
+
21
+ it('sticky-header is false by default', async () => {
22
+ const el = await fixture('<bds-table></bds-table>') as BdsTable;
23
+ expect(el.stickyHeader).toBe(false);
24
+ cleanup(el);
25
+ });
26
+
27
+ it('sticky-header attribute is reflected', async () => {
28
+ const el = await fixture('<bds-table sticky-header></bds-table>') as BdsTable;
29
+ expect(el.stickyHeader).toBe(true);
30
+ cleanup(el);
31
+ });
32
+
33
+ it('wrapper has overflow style when sticky-header is set', async () => {
34
+ const el = await fixture('<bds-table sticky-header></bds-table>');
35
+ const wrapper = el.shadowRoot!.querySelector('.wrapper') as HTMLElement;
36
+ expect(wrapper.style.cssText).toContain('overflow');
37
+ cleanup(el);
38
+ });
39
+
40
+ it('accepts slotted table content', async () => {
41
+ const el = await fixture('<bds-table><table><tbody><tr><td>Cell</td></tr></tbody></table></bds-table>');
42
+ expect(el.querySelector('table')).not.toBeNull();
43
+ cleanup(el);
44
+ });
45
+ });
@@ -0,0 +1,96 @@
1
+ import { LitElement, css, html } from 'lit';
2
+
3
+ /**
4
+ * `<bds-table>` — scrollable table wrapper with design-system styles.
5
+ *
6
+ * Slotted content should be a standard `<table>` with `<thead>` / `<tbody>`.
7
+ * Design-token styles are applied to the table and its cells via `::slotted`.
8
+ *
9
+ * Attributes:
10
+ * sticky-header — freezes `<thead>` at the top when the table overflows vertically
11
+ *
12
+ * Slots:
13
+ * (default) — a `<table>` element
14
+ *
15
+ * @example
16
+ * <bds-table sticky-header>
17
+ * <table>
18
+ * <thead><tr><th>Name</th><th>Value</th></tr></thead>
19
+ * <tbody><tr><td>Alpha</td><td>1</td></tr></tbody>
20
+ * </table>
21
+ * </bds-table>
22
+ */
23
+ export class BdsTable extends LitElement {
24
+ static styles = css`
25
+ :host {
26
+ display: block;
27
+ }
28
+
29
+ .wrapper {
30
+ overflow-inline: auto;
31
+ -webkit-overflow-scrolling: touch;
32
+ }
33
+
34
+ ::slotted(table) {
35
+ inline-size: 100%;
36
+ border-collapse: collapse;
37
+ font-size: var(--bds-font_size--body);
38
+ font-family: var(--bds-font_family--body);
39
+ color: var(--bds-color_on-bg);
40
+ }
41
+
42
+ ::slotted(thead) {
43
+ background-color: var(--bds-color_bg--subtle);
44
+ }
45
+
46
+ ::slotted(th) {
47
+ padding: var(--bds-space_xs) var(--bds-space_s);
48
+ font-weight: var(--bds-font_weight--semibold);
49
+ font-size: var(--bds-font_size--body--s);
50
+ text-align: start;
51
+ color: var(--bds-color_on-bg--subtle);
52
+ border-block-end: 1px solid var(--bds-color_bg--subtle);
53
+ white-space: nowrap;
54
+ }
55
+
56
+ ::slotted(td) {
57
+ padding: var(--bds-space_xs) var(--bds-space_s);
58
+ border-block-end: 1px solid var(--bds-color_bg--subtle);
59
+ vertical-align: middle;
60
+ }
61
+
62
+ ::slotted(tr:last-child td) {
63
+ border-block-end: none;
64
+ }
65
+ `;
66
+
67
+ static properties = {
68
+ stickyHeader: { type: Boolean, attribute: 'sticky-header', reflect: true },
69
+ };
70
+
71
+ declare stickyHeader: boolean;
72
+
73
+ constructor() {
74
+ super();
75
+ this.stickyHeader = false;
76
+ }
77
+
78
+ render() {
79
+ return html`
80
+ <div
81
+ class="wrapper"
82
+ style=${this.stickyHeader ? 'max-block-size: 100%; overflow-block: auto;' : ''}
83
+ >
84
+ <slot></slot>
85
+ </div>
86
+ `;
87
+ }
88
+ }
89
+
90
+ customElements.define('bds-table', BdsTable);
91
+
92
+ declare global {
93
+ interface HTMLElementTagNameMap {
94
+ 'bds-table': BdsTable;
95
+ }
96
+ }