@boostdev/design-system-components 1.2.0 → 1.2.1

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 (126) hide show
  1. package/AGENTS.md +25 -4
  2. package/dist/client.cjs +58 -54
  3. package/dist/client.css +503 -525
  4. package/dist/client.js +58 -54
  5. package/dist/index.cjs +58 -54
  6. package/dist/index.css +503 -525
  7. package/dist/index.js +58 -54
  8. package/dist/web-components/{chunk-6MH5UWUD.js → chunk-2FGATTGT.js} +5 -0
  9. package/dist/web-components/{chunk-5IPHEONG.js → chunk-6I2DBFQ7.js} +1 -0
  10. package/dist/web-components/{chunk-DI46Q2EA.js → chunk-OCODKRVZ.js} +57 -82
  11. package/dist/web-components/index.d.ts +571 -1
  12. package/dist/web-components/index.js +1501 -4
  13. package/dist/web-components/interaction/bds-collapsible.js +1 -1
  14. package/dist/web-components/interaction/bds-drawer.js +1 -1
  15. package/dist/web-components/interaction/form/bds-segmented-control.js +1 -1
  16. package/package.json +1 -1
  17. package/src/components/interaction/Drawer/Drawer.module.css +37 -62
  18. package/src/components/interaction/Drawer/Drawer.tsx +21 -17
  19. package/src/components/interaction/form/SegmentedControl/SegmentedControl.mdx +8 -4
  20. package/src/components/interaction/form/SegmentedControl/SegmentedControl.module.css +13 -6
  21. package/src/stories/Introduction.mdx +2 -1
  22. package/src/web-components/index.ts +12 -0
  23. package/src/web-components/interaction/BdsAccordion.mdx +80 -28
  24. package/src/web-components/interaction/BdsAccordion.stories.tsx +67 -58
  25. package/src/web-components/interaction/BdsCollapsible.mdx +93 -23
  26. package/src/web-components/interaction/BdsCollapsible.stories.tsx +29 -48
  27. package/src/web-components/interaction/BdsDialog.mdx +88 -27
  28. package/src/web-components/interaction/BdsDialog.stories.tsx +129 -47
  29. package/src/web-components/interaction/BdsDrawer.mdx +85 -27
  30. package/src/web-components/interaction/BdsDrawer.stories.tsx +161 -31
  31. package/src/web-components/interaction/BdsDropdownMenu.mdx +108 -0
  32. package/src/web-components/interaction/BdsDropdownMenu.stories.tsx +91 -0
  33. package/src/web-components/interaction/BdsSkipLink.mdx +72 -16
  34. package/src/web-components/interaction/BdsSkipLink.stories.tsx +47 -34
  35. package/src/web-components/interaction/BdsTabs.mdx +97 -27
  36. package/src/web-components/interaction/BdsTabs.stories.tsx +69 -55
  37. package/src/web-components/interaction/BdsTooltip.mdx +84 -18
  38. package/src/web-components/interaction/BdsTooltip.stories.tsx +49 -30
  39. package/src/web-components/interaction/bds-collapsible.ts +1 -0
  40. package/src/web-components/interaction/bds-drawer.ts +59 -82
  41. package/src/web-components/interaction/bds-dropdown-menu-item.ts +124 -0
  42. package/src/web-components/interaction/bds-dropdown-menu.spec.ts +102 -0
  43. package/src/web-components/interaction/bds-dropdown-menu.ts +200 -0
  44. package/src/web-components/interaction/form/BdsCheckbox.mdx +57 -20
  45. package/src/web-components/interaction/form/BdsCheckbox.stories.tsx +35 -36
  46. package/src/web-components/interaction/form/BdsCheckboxGroup.mdx +70 -0
  47. package/src/web-components/interaction/form/BdsCheckboxGroup.stories.tsx +81 -0
  48. package/src/web-components/interaction/form/BdsCombobox.mdx +52 -33
  49. package/src/web-components/interaction/form/BdsCombobox.stories.tsx +55 -53
  50. package/src/web-components/interaction/form/BdsFileInput.mdx +59 -24
  51. package/src/web-components/interaction/form/BdsFileInput.stories.tsx +38 -53
  52. package/src/web-components/interaction/form/BdsFormInput.mdx +98 -0
  53. package/src/web-components/interaction/form/BdsFormInput.stories.tsx +139 -0
  54. package/src/web-components/interaction/form/BdsNumberInput.mdx +51 -22
  55. package/src/web-components/interaction/form/BdsNumberInput.stories.tsx +24 -46
  56. package/src/web-components/interaction/form/BdsRadio.mdx +53 -22
  57. package/src/web-components/interaction/form/BdsRadio.stories.tsx +19 -51
  58. package/src/web-components/interaction/form/BdsRadioGroup.mdx +73 -0
  59. package/src/web-components/interaction/form/BdsRadioGroup.stories.tsx +88 -0
  60. package/src/web-components/interaction/form/BdsSegmentedControl.mdx +76 -23
  61. package/src/web-components/interaction/form/BdsSegmentedControl.stories.tsx +64 -54
  62. package/src/web-components/interaction/form/BdsSelect.mdx +59 -21
  63. package/src/web-components/interaction/form/BdsSelect.stories.tsx +47 -56
  64. package/src/web-components/interaction/form/BdsSlider.mdx +55 -27
  65. package/src/web-components/interaction/form/BdsSlider.stories.tsx +23 -44
  66. package/src/web-components/interaction/form/BdsSwitch.mdx +53 -20
  67. package/src/web-components/interaction/form/BdsSwitch.stories.tsx +22 -37
  68. package/src/web-components/interaction/form/BdsTextarea.mdx +54 -22
  69. package/src/web-components/interaction/form/BdsTextarea.stories.tsx +35 -44
  70. package/src/web-components/interaction/form/bds-checkbox-group.spec.ts +55 -0
  71. package/src/web-components/interaction/form/bds-checkbox-group.ts +117 -0
  72. package/src/web-components/interaction/form/bds-form-input.spec.ts +83 -0
  73. package/src/web-components/interaction/form/bds-form-input.ts +268 -0
  74. package/src/web-components/interaction/form/bds-radio-group.spec.ts +62 -0
  75. package/src/web-components/interaction/form/bds-radio-group.ts +142 -0
  76. package/src/web-components/interaction/form/bds-segmented-control.ts +5 -0
  77. package/src/web-components/ui/BdsAvatar.mdx +29 -20
  78. package/src/web-components/ui/BdsAvatar.stories.tsx +14 -28
  79. package/src/web-components/ui/BdsBreadcrumb.mdx +22 -19
  80. package/src/web-components/ui/BdsBreadcrumb.stories.tsx +11 -21
  81. package/src/web-components/ui/BdsButtonGroup.mdx +69 -0
  82. package/src/web-components/ui/BdsButtonGroup.stories.tsx +66 -0
  83. package/src/web-components/ui/BdsCalendar.mdx +93 -0
  84. package/src/web-components/ui/BdsCalendar.stories.tsx +63 -0
  85. package/src/web-components/ui/BdsCard.mdx +30 -25
  86. package/src/web-components/ui/BdsCard.stories.tsx +19 -42
  87. package/src/web-components/ui/BdsCarousel.mdx +83 -0
  88. package/src/web-components/ui/BdsCarousel.stories.tsx +96 -0
  89. package/src/web-components/ui/BdsDescriptionList.mdx +13 -16
  90. package/src/web-components/ui/BdsDescriptionList.stories.tsx +34 -49
  91. package/src/web-components/ui/BdsIconWrapper.mdx +20 -20
  92. package/src/web-components/ui/BdsIconWrapper.stories.tsx +46 -41
  93. package/src/web-components/ui/BdsLink.mdx +21 -17
  94. package/src/web-components/ui/BdsLink.stories.tsx +19 -20
  95. package/src/web-components/ui/BdsLoading.mdx +9 -13
  96. package/src/web-components/ui/BdsLoading.stories.tsx +8 -10
  97. package/src/web-components/ui/BdsNotificationBanner.mdx +32 -37
  98. package/src/web-components/ui/BdsNotificationBanner.stories.tsx +38 -47
  99. package/src/web-components/ui/BdsPagination.mdx +74 -0
  100. package/src/web-components/ui/BdsPagination.stories.tsx +36 -0
  101. package/src/web-components/ui/BdsProgress.mdx +24 -17
  102. package/src/web-components/ui/BdsProgress.stories.tsx +55 -29
  103. package/src/web-components/ui/BdsProgressCircle.mdx +24 -18
  104. package/src/web-components/ui/BdsProgressCircle.stories.tsx +17 -29
  105. package/src/web-components/ui/BdsRating.mdx +22 -22
  106. package/src/web-components/ui/BdsRating.stories.tsx +19 -13
  107. package/src/web-components/ui/BdsSectionHeader.mdx +31 -28
  108. package/src/web-components/ui/BdsSectionHeader.stories.tsx +21 -23
  109. package/src/web-components/ui/BdsSeparator.mdx +22 -8
  110. package/src/web-components/ui/BdsSeparator.stories.tsx +27 -19
  111. package/src/web-components/ui/BdsSkeleton.mdx +20 -26
  112. package/src/web-components/ui/BdsSkeleton.stories.tsx +23 -23
  113. package/src/web-components/ui/BdsTable.mdx +81 -0
  114. package/src/web-components/ui/BdsTable.stories.tsx +83 -0
  115. package/src/web-components/ui/BdsTypography.mdx +20 -29
  116. package/src/web-components/ui/BdsTypography.stories.tsx +19 -18
  117. package/src/web-components/ui/bds-button-group.spec.ts +40 -0
  118. package/src/web-components/ui/bds-button-group.ts +78 -0
  119. package/src/web-components/ui/bds-calendar.spec.ts +91 -0
  120. package/src/web-components/ui/bds-calendar.ts +427 -0
  121. package/src/web-components/ui/bds-carousel.spec.ts +64 -0
  122. package/src/web-components/ui/bds-carousel.ts +296 -0
  123. package/src/web-components/ui/bds-pagination.spec.ts +67 -0
  124. package/src/web-components/ui/bds-pagination.ts +197 -0
  125. package/src/web-components/ui/bds-table.spec.ts +45 -0
  126. package/src/web-components/ui/bds-table.ts +96 -0
@@ -0,0 +1,78 @@
1
+ import { LitElement, css, html } from 'lit';
2
+
3
+ export type ButtonGroupOrientation = 'horizontal' | 'vertical';
4
+
5
+ /**
6
+ * `<bds-button-group>` — groups related buttons into a flex container.
7
+ *
8
+ * Attributes:
9
+ * orientation — `"horizontal"` (default) | `"vertical"`
10
+ * aria-label — accessible label for the group
11
+ *
12
+ * Slots:
13
+ * (default) — `<bds-button>` elements
14
+ *
15
+ * @example
16
+ * <bds-button-group aria-label="View options">
17
+ * <bds-button>Day</bds-button>
18
+ * <bds-button>Week</bds-button>
19
+ * <bds-button>Month</bds-button>
20
+ * </bds-button-group>
21
+ */
22
+ export class BdsButtonGroup extends LitElement {
23
+ static styles = css`
24
+ :host {
25
+ display: block;
26
+ }
27
+
28
+ .group {
29
+ display: inline-flex;
30
+ flex-direction: row;
31
+ align-items: center;
32
+ gap: 0;
33
+ }
34
+
35
+ :host([orientation='vertical']) .group {
36
+ flex-direction: column;
37
+ align-items: stretch;
38
+ }
39
+
40
+ /* Collapse border radius between adjacent buttons */
41
+ ::slotted(*:not(:first-child)) {
42
+ --button_radius-start: 0;
43
+ }
44
+
45
+ ::slotted(*:not(:last-child)) {
46
+ --button_radius-end: 0;
47
+ }
48
+ `;
49
+
50
+ static properties = {
51
+ orientation: { type: String, reflect: true },
52
+ 'aria-label': { type: String },
53
+ };
54
+
55
+ declare orientation: ButtonGroupOrientation;
56
+ declare ['aria-label']: string | undefined;
57
+
58
+ constructor() {
59
+ super();
60
+ this.orientation = 'horizontal';
61
+ }
62
+
63
+ render() {
64
+ return html`
65
+ <div class="group" role="group" aria-label=${this['aria-label'] ?? ''}>
66
+ <slot></slot>
67
+ </div>
68
+ `;
69
+ }
70
+ }
71
+
72
+ customElements.define('bds-button-group', BdsButtonGroup);
73
+
74
+ declare global {
75
+ interface HTMLElementTagNameMap {
76
+ 'bds-button-group': BdsButtonGroup;
77
+ }
78
+ }
@@ -0,0 +1,91 @@
1
+ import { fixture, cleanup } from '../test/helpers';
2
+ import { BdsCalendar } from './bds-calendar';
3
+
4
+ describe('bds-calendar', () => {
5
+ it('is registered as a custom element', () => {
6
+ expect(customElements.get('bds-calendar')).toBe(BdsCalendar);
7
+ });
8
+
9
+ it('is form-associated', () => {
10
+ expect(BdsCalendar.formAssociated).toBe(true);
11
+ });
12
+
13
+ it('renders a calendar grid', async () => {
14
+ const el = await fixture('<bds-calendar></bds-calendar>');
15
+ expect(el.shadowRoot!.querySelector('.grid')).not.toBeNull();
16
+ cleanup(el);
17
+ });
18
+
19
+ it('renders prev and next month navigation buttons', async () => {
20
+ const el = await fixture('<bds-calendar></bds-calendar>');
21
+ expect(el.shadowRoot!.querySelector('[aria-label="Previous month"]')).not.toBeNull();
22
+ expect(el.shadowRoot!.querySelector('[aria-label="Next month"]')).not.toBeNull();
23
+ cleanup(el);
24
+ });
25
+
26
+ it('renders 7 weekday column headers', async () => {
27
+ const el = await fixture('<bds-calendar></bds-calendar>');
28
+ const headers = el.shadowRoot!.querySelectorAll('.weekday');
29
+ expect(headers.length).toBe(7);
30
+ cleanup(el);
31
+ });
32
+
33
+ it('marks today with aria-current="date"', async () => {
34
+ const el = await fixture('<bds-calendar></bds-calendar>');
35
+ const today = el.shadowRoot!.querySelector('[aria-current="date"]');
36
+ expect(today).not.toBeNull();
37
+ cleanup(el);
38
+ });
39
+
40
+ it('marks selected date with aria-selected="true"', async () => {
41
+ const today = new Date();
42
+ const iso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
43
+ const el = await fixture(`<bds-calendar value="${iso}"></bds-calendar>`);
44
+ const selected = el.shadowRoot!.querySelector('[aria-selected="true"]');
45
+ expect(selected).not.toBeNull();
46
+ cleanup(el);
47
+ });
48
+
49
+ it('fires bds-date-select when a date is clicked', async () => {
50
+ const el = await fixture('<bds-calendar></bds-calendar>');
51
+ const dates: string[] = [];
52
+ el.addEventListener('bds-date-select', (e: Event) => {
53
+ dates.push((e as CustomEvent).detail.date);
54
+ });
55
+ const dayBtn = el.shadowRoot!.querySelector<HTMLButtonElement>('.day:not(.--outside):not([disabled])')!;
56
+ dayBtn.click();
57
+ expect(dates.length).toBe(1);
58
+ expect(dates[0]).toMatch(/^\d{4}-\d{2}-\d{2}$/);
59
+ cleanup(el);
60
+ });
61
+
62
+ it('fires bds-date-select with a valid ISO date string', async () => {
63
+ const el = await fixture('<bds-calendar></bds-calendar>') as BdsCalendar;
64
+ const dates: string[] = [];
65
+ el.addEventListener('bds-date-select', (e: Event) => {
66
+ dates.push((e as CustomEvent).detail.date);
67
+ });
68
+ const dayBtns = el.shadowRoot!.querySelectorAll<HTMLButtonElement>('.day:not(.--outside):not([disabled])');
69
+ dayBtns[0].click();
70
+ expect(dates.length).toBe(1);
71
+ expect(dates[0]).toMatch(/^\d{4}-\d{2}-\d{2}$/);
72
+ cleanup(el);
73
+ });
74
+
75
+ it('navigates to next month on next button click', async () => {
76
+ const el = await fixture('<bds-calendar value="2024-01-15"></bds-calendar>') as BdsCalendar;
77
+ const label = el.shadowRoot!.querySelector('.month-label')!;
78
+ const initialLabel = label.textContent;
79
+ el.shadowRoot!.querySelector<HTMLButtonElement>('[aria-label="Next month"]')!.click();
80
+ await el.updateComplete;
81
+ expect(label.textContent).not.toBe(initialLabel);
82
+ cleanup(el);
83
+ });
84
+
85
+ it('disables dates outside min/max range', async () => {
86
+ const el = await fixture('<bds-calendar value="2024-03-15" min="2024-03-10" max="2024-03-20"></bds-calendar>');
87
+ const disabled = el.shadowRoot!.querySelectorAll<HTMLButtonElement>('[aria-disabled="true"]');
88
+ expect(disabled.length).toBeGreaterThan(0);
89
+ cleanup(el);
90
+ });
91
+ });
@@ -0,0 +1,427 @@
1
+ import { LitElement, css, html } from 'lit';
2
+
3
+ /** Format a Date to ISO 8601 `YYYY-MM-DD` string in local time. */
4
+ function toISO(date: Date): string {
5
+ const y = date.getFullYear();
6
+ const m = String(date.getMonth() + 1).padStart(2, '0');
7
+ const d = String(date.getDate()).padStart(2, '0');
8
+ return `${y}-${m}-${d}`;
9
+ }
10
+
11
+ /** Parse `YYYY-MM-DD` to a local-time Date, or null if invalid. */
12
+ function parseISO(iso: string): Date | null {
13
+ if (!iso) return null;
14
+ const [y, m, d] = iso.split('-').map(Number);
15
+ if (!y || !m || !d) return null;
16
+ const date = new Date(y, m - 1, d);
17
+ return isNaN(date.getTime()) ? null : date;
18
+ }
19
+
20
+ /**
21
+ * `<bds-calendar>` — form-associated date picker calendar.
22
+ *
23
+ * Renders a month grid for selecting a date. Keyboard-navigable. Participates
24
+ * in HTML forms via the Form-Associated Custom Elements API.
25
+ *
26
+ * Attributes:
27
+ * name — form field name
28
+ * value — selected date in ISO 8601 format (`YYYY-MM-DD`)
29
+ * min — minimum selectable date (ISO 8601)
30
+ * max — maximum selectable date (ISO 8601)
31
+ * disabled — disables all interaction
32
+ *
33
+ * Events:
34
+ * bds-date-select — fired when user selects a date; `detail: { date: string }` (ISO 8601)
35
+ *
36
+ * @example
37
+ * <bds-calendar name="birthday" value="2024-03-15"></bds-calendar>
38
+ */
39
+ export class BdsCalendar extends LitElement {
40
+ static formAssociated = true;
41
+
42
+ static styles = css`
43
+ :host {
44
+ display: inline-block;
45
+ font-family: var(--bds-font_family--body);
46
+ font-size: var(--bds-font_size--body);
47
+ }
48
+
49
+ :host([disabled]) {
50
+ opacity: 0.5;
51
+ pointer-events: none;
52
+ }
53
+
54
+ .calendar {
55
+ background-color: var(--bds-color_bg);
56
+ border-radius: var(--bds-border_radius--m);
57
+ box-shadow: var(--bds-shadow_s);
58
+ padding: var(--bds-space_s);
59
+ inline-size: fit-content;
60
+ }
61
+
62
+ .header {
63
+ display: flex;
64
+ align-items: center;
65
+ justify-content: space-between;
66
+ margin-block-end: var(--bds-space_s);
67
+ }
68
+
69
+ .month-label {
70
+ font-weight: var(--bds-font_weight--semibold);
71
+ color: var(--bds-color_on-bg);
72
+ }
73
+
74
+ .nav-btn {
75
+ display: inline-flex;
76
+ align-items: center;
77
+ justify-content: center;
78
+ inline-size: 2rem;
79
+ block-size: 2rem;
80
+ border: none;
81
+ border-radius: var(--bds-border_radius--xs);
82
+ background: none;
83
+ color: var(--bds-color_on-bg);
84
+ cursor: pointer;
85
+ transition: var(--bds-animation_transition);
86
+ }
87
+
88
+ @media (hover: hover) and (pointer: fine) {
89
+ .nav-btn:hover {
90
+ background-color: var(--bds-color_bg--subtle);
91
+ }
92
+ }
93
+
94
+ .nav-btn:focus-visible {
95
+ outline: var(--bds-outline_default);
96
+ outline-offset: var(--bds-outline_offset);
97
+ }
98
+
99
+ .nav-btn svg {
100
+ inline-size: 1rem;
101
+ block-size: 1rem;
102
+ }
103
+
104
+ .grid {
105
+ display: grid;
106
+ grid-template-columns: repeat(7, 2.25rem);
107
+ gap: var(--bds-space_xxxs);
108
+ }
109
+
110
+ .weekday {
111
+ display: flex;
112
+ align-items: center;
113
+ justify-content: center;
114
+ block-size: 2rem;
115
+ font-size: var(--bds-font_size--body--s);
116
+ font-weight: var(--bds-font_weight--semibold);
117
+ color: var(--bds-color_on-bg--subtle);
118
+ user-select: none;
119
+ }
120
+
121
+ .day {
122
+ display: flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ inline-size: 2.25rem;
126
+ block-size: 2.25rem;
127
+ border: none;
128
+ border-radius: var(--bds-border_radius--xs);
129
+ background: none;
130
+ color: var(--bds-color_on-bg);
131
+ cursor: pointer;
132
+ font-size: var(--bds-font_size--body--s);
133
+ font-family: inherit;
134
+ transition: var(--bds-animation_transition);
135
+ }
136
+
137
+ .day:focus-visible {
138
+ outline: var(--bds-outline_default);
139
+ outline-offset: var(--bds-outline_offset);
140
+ }
141
+
142
+ @media (hover: hover) and (pointer: fine) {
143
+ .day:not(.--selected):not(.--outside):not([disabled]):hover {
144
+ background-color: var(--bds-color_bg--subtle);
145
+ }
146
+ }
147
+
148
+ .day.--today {
149
+ font-weight: var(--bds-font_weight--semibold);
150
+ color: var(--bds-color_interactive_on-bg);
151
+ }
152
+
153
+ .day.--selected {
154
+ background-color: var(--bds-color_interactive);
155
+ color: var(--bds-color_on-interactive);
156
+ font-weight: var(--bds-font_weight--semibold);
157
+ }
158
+
159
+ .day.--outside {
160
+ color: var(--bds-color_on-bg--subtle);
161
+ opacity: 0.4;
162
+ }
163
+
164
+ .day[disabled] {
165
+ opacity: 0.3;
166
+ cursor: not-allowed;
167
+ }
168
+ `;
169
+
170
+ static properties = {
171
+ name: { type: String, reflect: true },
172
+ value: { type: String, reflect: true },
173
+ min: { type: String },
174
+ max: { type: String },
175
+ disabled: { type: Boolean, reflect: true },
176
+ };
177
+
178
+ declare name: string;
179
+ declare value: string;
180
+ declare min: string;
181
+ declare max: string;
182
+ declare disabled: boolean;
183
+
184
+ private _internals!: ElementInternals;
185
+ private _viewYear: number;
186
+ private _viewMonth: number; // 0-based
187
+
188
+ constructor() {
189
+ super();
190
+ this._internals = this.attachInternals();
191
+ this.name = '';
192
+ this.value = '';
193
+ this.min = '';
194
+ this.max = '';
195
+ this.disabled = false;
196
+
197
+ const now = new Date();
198
+ this._viewYear = now.getFullYear();
199
+ this._viewMonth = now.getMonth();
200
+ }
201
+
202
+ updated(changed: Map<string, unknown>) {
203
+ super.updated(changed);
204
+ if (changed.has('value')) {
205
+ this._internals.setFormValue?.(this.value);
206
+ if (this.value) {
207
+ const d = parseISO(this.value);
208
+ if (d) {
209
+ this._viewYear = d.getFullYear();
210
+ this._viewMonth = d.getMonth();
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ formResetCallback() {
217
+ this.value = '';
218
+ this._internals.setFormValue?.('');
219
+ }
220
+
221
+ private _prevMonth() {
222
+ if (this._viewMonth === 0) {
223
+ this._viewMonth = 11;
224
+ this._viewYear--;
225
+ } else {
226
+ this._viewMonth--;
227
+ }
228
+ this.requestUpdate();
229
+ }
230
+
231
+ private _nextMonth() {
232
+ if (this._viewMonth === 11) {
233
+ this._viewMonth = 0;
234
+ this._viewYear++;
235
+ } else {
236
+ this._viewMonth++;
237
+ }
238
+ this.requestUpdate();
239
+ }
240
+
241
+ private _selectDate(iso: string) {
242
+ if (this._isDisabledDate(iso)) return;
243
+ this.value = iso;
244
+ this._internals.setFormValue?.(iso);
245
+ this.dispatchEvent(new CustomEvent('bds-date-select', {
246
+ detail: { date: iso },
247
+ bubbles: true,
248
+ composed: true,
249
+ }));
250
+ }
251
+
252
+ private _isDisabledDate(iso: string): boolean {
253
+ if (this.min && iso < this.min) return true;
254
+ if (this.max && iso > this.max) return true;
255
+ return false;
256
+ }
257
+
258
+ private _handleKeydown = (e: KeyboardEvent) => {
259
+ const focused = this.shadowRoot?.activeElement as HTMLButtonElement | null;
260
+ if (!focused?.dataset.date) return;
261
+
262
+ const current = parseISO(focused.dataset.date);
263
+ if (!current) return;
264
+
265
+ let next: Date | null = null;
266
+
267
+ if (e.key === 'ArrowRight') {
268
+ e.preventDefault();
269
+ next = new Date(current.getFullYear(), current.getMonth(), current.getDate() + 1);
270
+ } else if (e.key === 'ArrowLeft') {
271
+ e.preventDefault();
272
+ next = new Date(current.getFullYear(), current.getMonth(), current.getDate() - 1);
273
+ } else if (e.key === 'ArrowDown') {
274
+ e.preventDefault();
275
+ next = new Date(current.getFullYear(), current.getMonth(), current.getDate() + 7);
276
+ } else if (e.key === 'ArrowUp') {
277
+ e.preventDefault();
278
+ next = new Date(current.getFullYear(), current.getMonth(), current.getDate() - 7);
279
+ } else if (e.key === 'Home') {
280
+ e.preventDefault();
281
+ const dow = current.getDay(); // 0 = Sunday
282
+ next = new Date(current.getFullYear(), current.getMonth(), current.getDate() - dow);
283
+ } else if (e.key === 'End') {
284
+ e.preventDefault();
285
+ const dow = current.getDay();
286
+ next = new Date(current.getFullYear(), current.getMonth(), current.getDate() + (6 - dow));
287
+ } else if (e.key === 'PageUp') {
288
+ e.preventDefault();
289
+ this._prevMonth();
290
+ return;
291
+ } else if (e.key === 'PageDown') {
292
+ e.preventDefault();
293
+ this._nextMonth();
294
+ return;
295
+ }
296
+
297
+ if (next) {
298
+ const iso = toISO(next);
299
+ // Navigate view if needed
300
+ if (next.getMonth() !== this._viewMonth || next.getFullYear() !== this._viewYear) {
301
+ this._viewYear = next.getFullYear();
302
+ this._viewMonth = next.getMonth();
303
+ this.requestUpdate();
304
+ // Focus after update
305
+ this.updateComplete.then(() => {
306
+ this.shadowRoot?.querySelector<HTMLButtonElement>(`[data-date="${iso}"]`)?.focus();
307
+ });
308
+ } else {
309
+ this.shadowRoot?.querySelector<HTMLButtonElement>(`[data-date="${iso}"]`)?.focus();
310
+ }
311
+ }
312
+ };
313
+
314
+ private _buildGrid(): { iso: string; outside: boolean }[] {
315
+ const year = this._viewYear;
316
+ const month = this._viewMonth;
317
+
318
+ // Days in month
319
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
320
+ // First weekday of the month (0=Sun … 6=Sat)
321
+ const firstDow = new Date(year, month, 1).getDay();
322
+
323
+ const cells: { iso: string; outside: boolean }[] = [];
324
+
325
+ // Leading days from previous month
326
+ const prevMonthDays = new Date(year, month, 0).getDate();
327
+ for (let i = firstDow - 1; i >= 0; i--) {
328
+ const d = new Date(year, month - 1, prevMonthDays - i);
329
+ cells.push({ iso: toISO(d), outside: true });
330
+ }
331
+
332
+ // Current month days
333
+ for (let d = 1; d <= daysInMonth; d++) {
334
+ cells.push({ iso: toISO(new Date(year, month, d)), outside: false });
335
+ }
336
+
337
+ // Trailing days to complete last row
338
+ const trailing = (7 - (cells.length % 7)) % 7;
339
+ for (let d = 1; d <= trailing; d++) {
340
+ cells.push({ iso: toISO(new Date(year, month + 1, d)), outside: true });
341
+ }
342
+
343
+ return cells;
344
+ }
345
+
346
+ render() {
347
+ const today = toISO(new Date());
348
+ const cells = this._buildGrid();
349
+
350
+ const fmt = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
351
+ const monthLabel = fmt.format(new Date(this._viewYear, this._viewMonth, 1));
352
+
353
+ const weekdayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' });
354
+ const weekdays = Array.from({ length: 7 }, (_, i) => {
355
+ const sunday = new Date(2023, 0, 1 + i); // Jan 1 2023 = Sunday
356
+ return weekdayFmt.format(sunday);
357
+ });
358
+
359
+ return html`
360
+ <div class="calendar" @keydown=${this._handleKeydown}>
361
+ <div class="header">
362
+ <button
363
+ class="nav-btn"
364
+ type="button"
365
+ aria-label="Previous month"
366
+ @click=${this._prevMonth}
367
+ >
368
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
369
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
370
+ </svg>
371
+ </button>
372
+ <span class="month-label" aria-live="polite">${monthLabel}</span>
373
+ <button
374
+ class="nav-btn"
375
+ type="button"
376
+ aria-label="Next month"
377
+ @click=${this._nextMonth}
378
+ >
379
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
380
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
381
+ </svg>
382
+ </button>
383
+ </div>
384
+
385
+ <div class="grid" role="grid" aria-label=${monthLabel}>
386
+ ${weekdays.map(day => html`<div class="weekday" role="columnheader" aria-label=${day}>${day}</div>`)}
387
+ ${cells.map(({ iso, outside }) => {
388
+ const isSelected = iso === this.value;
389
+ const isToday = iso === today;
390
+ const isDisabled = this._isDisabledDate(iso);
391
+ const classes = [
392
+ 'day',
393
+ outside ? '--outside' : '',
394
+ isSelected ? '--selected' : '',
395
+ isToday && !isSelected ? '--today' : '',
396
+ ].filter(Boolean).join(' ');
397
+
398
+ return html`
399
+ <button
400
+ class=${classes}
401
+ type="button"
402
+ role="gridcell"
403
+ data-date=${iso}
404
+ ?disabled=${isDisabled || outside}
405
+ aria-selected=${isSelected ? 'true' : 'false'}
406
+ aria-current=${isToday ? 'date' : 'false'}
407
+ aria-disabled=${isDisabled ? 'true' : 'false'}
408
+ tabindex=${isSelected || (!this.value && isToday) ? '0' : '-1'}
409
+ @click=${() => this._selectDate(iso)}
410
+ >
411
+ ${new Date(iso + 'T00:00:00').getDate()}
412
+ </button>
413
+ `;
414
+ })}
415
+ </div>
416
+ </div>
417
+ `;
418
+ }
419
+ }
420
+
421
+ customElements.define('bds-calendar', BdsCalendar);
422
+
423
+ declare global {
424
+ interface HTMLElementTagNameMap {
425
+ 'bds-calendar': BdsCalendar;
426
+ }
427
+ }
@@ -0,0 +1,64 @@
1
+ import { fixture, cleanup } from '../test/helpers';
2
+ import { BdsCarousel } from './bds-carousel';
3
+
4
+ describe('bds-carousel', () => {
5
+ it('is registered as a custom element', () => {
6
+ expect(customElements.get('bds-carousel')).toBe(BdsCarousel);
7
+ });
8
+
9
+ it('renders a track region', async () => {
10
+ const el = await fixture('<bds-carousel><div>Slide 1</div></bds-carousel>');
11
+ expect(el.shadowRoot!.querySelector('.track')).not.toBeNull();
12
+ cleanup(el);
13
+ });
14
+
15
+ it('defaults current-index to 0', async () => {
16
+ const el = await fixture('<bds-carousel><div>S1</div></bds-carousel>') as BdsCarousel;
17
+ expect(el.currentIndex).toBe(0);
18
+ cleanup(el);
19
+ });
20
+
21
+ it('renders prev and next buttons', async () => {
22
+ const el = await fixture('<bds-carousel><div>S1</div><div>S2</div></bds-carousel>');
23
+ const prev = el.shadowRoot!.querySelector('[aria-label="Previous slide"]');
24
+ const next = el.shadowRoot!.querySelector('[aria-label="Next slide"]');
25
+ expect(prev).not.toBeNull();
26
+ expect(next).not.toBeNull();
27
+ cleanup(el);
28
+ });
29
+
30
+ it('prev button is disabled when loop is off and at first slide', async () => {
31
+ const el = await fixture('<bds-carousel><div>S1</div><div>S2</div></bds-carousel>');
32
+ const prev = el.shadowRoot!.querySelector<HTMLButtonElement>('[aria-label="Previous slide"]')!;
33
+ expect(prev.disabled).toBe(true);
34
+ cleanup(el);
35
+ });
36
+
37
+ it('next button is disabled when loop is off and at last slide', async () => {
38
+ const el = await fixture('<bds-carousel current-index="1"><div>S1</div><div>S2</div></bds-carousel>') as BdsCarousel;
39
+ await el.updateComplete;
40
+ const next = el.shadowRoot!.querySelector<HTMLButtonElement>('[aria-label="Next slide"]')!;
41
+ expect(next.disabled).toBe(true);
42
+ cleanup(el);
43
+ });
44
+
45
+ it('fires bds-slide-change on next click', async () => {
46
+ const el = await fixture('<bds-carousel><div>S1</div><div>S2</div></bds-carousel>') as BdsCarousel;
47
+ const changes: number[] = [];
48
+ el.addEventListener('bds-slide-change', (e: Event) => {
49
+ changes.push((e as CustomEvent).detail.index);
50
+ });
51
+ const next = el.shadowRoot!.querySelector<HTMLButtonElement>('[aria-label="Next slide"]')!;
52
+ next.click();
53
+ expect(changes).toEqual([1]);
54
+ cleanup(el);
55
+ });
56
+
57
+ it('renders dot indicators when indicators attribute is set', async () => {
58
+ const el = await fixture('<bds-carousel indicators><div>S1</div><div>S2</div><div>S3</div></bds-carousel>');
59
+ await el.updateComplete;
60
+ const dots = el.shadowRoot!.querySelectorAll('.dot');
61
+ expect(dots.length).toBe(3);
62
+ cleanup(el);
63
+ });
64
+ });