@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.
- package/AGENTS.md +25 -4
- package/dist/client.cjs +58 -54
- package/dist/client.css +503 -525
- package/dist/client.js +58 -54
- package/dist/index.cjs +58 -54
- package/dist/index.css +503 -525
- package/dist/index.js +58 -54
- package/dist/web-components/{chunk-6MH5UWUD.js → chunk-2FGATTGT.js} +5 -0
- package/dist/web-components/{chunk-5IPHEONG.js → chunk-6I2DBFQ7.js} +1 -0
- package/dist/web-components/{chunk-DI46Q2EA.js → chunk-OCODKRVZ.js} +57 -82
- package/dist/web-components/index.d.ts +571 -1
- package/dist/web-components/index.js +1501 -4
- package/dist/web-components/interaction/bds-collapsible.js +1 -1
- package/dist/web-components/interaction/bds-drawer.js +1 -1
- package/dist/web-components/interaction/form/bds-segmented-control.js +1 -1
- package/package.json +1 -1
- package/src/components/interaction/Drawer/Drawer.module.css +37 -62
- package/src/components/interaction/Drawer/Drawer.tsx +21 -17
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.mdx +8 -4
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.module.css +13 -6
- package/src/stories/Introduction.mdx +2 -1
- package/src/web-components/index.ts +12 -0
- package/src/web-components/interaction/BdsAccordion.mdx +80 -28
- package/src/web-components/interaction/BdsAccordion.stories.tsx +67 -58
- package/src/web-components/interaction/BdsCollapsible.mdx +93 -23
- package/src/web-components/interaction/BdsCollapsible.stories.tsx +29 -48
- package/src/web-components/interaction/BdsDialog.mdx +88 -27
- package/src/web-components/interaction/BdsDialog.stories.tsx +129 -47
- package/src/web-components/interaction/BdsDrawer.mdx +85 -27
- package/src/web-components/interaction/BdsDrawer.stories.tsx +161 -31
- package/src/web-components/interaction/BdsDropdownMenu.mdx +108 -0
- package/src/web-components/interaction/BdsDropdownMenu.stories.tsx +91 -0
- package/src/web-components/interaction/BdsSkipLink.mdx +72 -16
- package/src/web-components/interaction/BdsSkipLink.stories.tsx +47 -34
- package/src/web-components/interaction/BdsTabs.mdx +97 -27
- package/src/web-components/interaction/BdsTabs.stories.tsx +69 -55
- package/src/web-components/interaction/BdsTooltip.mdx +84 -18
- package/src/web-components/interaction/BdsTooltip.stories.tsx +49 -30
- package/src/web-components/interaction/bds-collapsible.ts +1 -0
- package/src/web-components/interaction/bds-drawer.ts +59 -82
- package/src/web-components/interaction/bds-dropdown-menu-item.ts +124 -0
- package/src/web-components/interaction/bds-dropdown-menu.spec.ts +102 -0
- package/src/web-components/interaction/bds-dropdown-menu.ts +200 -0
- package/src/web-components/interaction/form/BdsCheckbox.mdx +57 -20
- package/src/web-components/interaction/form/BdsCheckbox.stories.tsx +35 -36
- package/src/web-components/interaction/form/BdsCheckboxGroup.mdx +70 -0
- package/src/web-components/interaction/form/BdsCheckboxGroup.stories.tsx +81 -0
- package/src/web-components/interaction/form/BdsCombobox.mdx +52 -33
- package/src/web-components/interaction/form/BdsCombobox.stories.tsx +55 -53
- package/src/web-components/interaction/form/BdsFileInput.mdx +59 -24
- package/src/web-components/interaction/form/BdsFileInput.stories.tsx +38 -53
- package/src/web-components/interaction/form/BdsFormInput.mdx +98 -0
- package/src/web-components/interaction/form/BdsFormInput.stories.tsx +139 -0
- package/src/web-components/interaction/form/BdsNumberInput.mdx +51 -22
- package/src/web-components/interaction/form/BdsNumberInput.stories.tsx +24 -46
- package/src/web-components/interaction/form/BdsRadio.mdx +53 -22
- package/src/web-components/interaction/form/BdsRadio.stories.tsx +19 -51
- package/src/web-components/interaction/form/BdsRadioGroup.mdx +73 -0
- package/src/web-components/interaction/form/BdsRadioGroup.stories.tsx +88 -0
- package/src/web-components/interaction/form/BdsSegmentedControl.mdx +76 -23
- package/src/web-components/interaction/form/BdsSegmentedControl.stories.tsx +64 -54
- package/src/web-components/interaction/form/BdsSelect.mdx +59 -21
- package/src/web-components/interaction/form/BdsSelect.stories.tsx +47 -56
- package/src/web-components/interaction/form/BdsSlider.mdx +55 -27
- package/src/web-components/interaction/form/BdsSlider.stories.tsx +23 -44
- package/src/web-components/interaction/form/BdsSwitch.mdx +53 -20
- package/src/web-components/interaction/form/BdsSwitch.stories.tsx +22 -37
- package/src/web-components/interaction/form/BdsTextarea.mdx +54 -22
- package/src/web-components/interaction/form/BdsTextarea.stories.tsx +35 -44
- package/src/web-components/interaction/form/bds-checkbox-group.spec.ts +55 -0
- package/src/web-components/interaction/form/bds-checkbox-group.ts +117 -0
- package/src/web-components/interaction/form/bds-form-input.spec.ts +83 -0
- package/src/web-components/interaction/form/bds-form-input.ts +268 -0
- package/src/web-components/interaction/form/bds-radio-group.spec.ts +62 -0
- package/src/web-components/interaction/form/bds-radio-group.ts +142 -0
- package/src/web-components/interaction/form/bds-segmented-control.ts +5 -0
- package/src/web-components/ui/BdsAvatar.mdx +29 -20
- package/src/web-components/ui/BdsAvatar.stories.tsx +14 -28
- package/src/web-components/ui/BdsBreadcrumb.mdx +22 -19
- package/src/web-components/ui/BdsBreadcrumb.stories.tsx +11 -21
- package/src/web-components/ui/BdsButtonGroup.mdx +69 -0
- package/src/web-components/ui/BdsButtonGroup.stories.tsx +66 -0
- package/src/web-components/ui/BdsCalendar.mdx +93 -0
- package/src/web-components/ui/BdsCalendar.stories.tsx +63 -0
- package/src/web-components/ui/BdsCard.mdx +30 -25
- package/src/web-components/ui/BdsCard.stories.tsx +19 -42
- package/src/web-components/ui/BdsCarousel.mdx +83 -0
- package/src/web-components/ui/BdsCarousel.stories.tsx +96 -0
- package/src/web-components/ui/BdsDescriptionList.mdx +13 -16
- package/src/web-components/ui/BdsDescriptionList.stories.tsx +34 -49
- package/src/web-components/ui/BdsIconWrapper.mdx +20 -20
- package/src/web-components/ui/BdsIconWrapper.stories.tsx +46 -41
- package/src/web-components/ui/BdsLink.mdx +21 -17
- package/src/web-components/ui/BdsLink.stories.tsx +19 -20
- package/src/web-components/ui/BdsLoading.mdx +9 -13
- package/src/web-components/ui/BdsLoading.stories.tsx +8 -10
- package/src/web-components/ui/BdsNotificationBanner.mdx +32 -37
- package/src/web-components/ui/BdsNotificationBanner.stories.tsx +38 -47
- package/src/web-components/ui/BdsPagination.mdx +74 -0
- package/src/web-components/ui/BdsPagination.stories.tsx +36 -0
- package/src/web-components/ui/BdsProgress.mdx +24 -17
- package/src/web-components/ui/BdsProgress.stories.tsx +55 -29
- package/src/web-components/ui/BdsProgressCircle.mdx +24 -18
- package/src/web-components/ui/BdsProgressCircle.stories.tsx +17 -29
- package/src/web-components/ui/BdsRating.mdx +22 -22
- package/src/web-components/ui/BdsRating.stories.tsx +19 -13
- package/src/web-components/ui/BdsSectionHeader.mdx +31 -28
- package/src/web-components/ui/BdsSectionHeader.stories.tsx +21 -23
- package/src/web-components/ui/BdsSeparator.mdx +22 -8
- package/src/web-components/ui/BdsSeparator.stories.tsx +27 -19
- package/src/web-components/ui/BdsSkeleton.mdx +20 -26
- package/src/web-components/ui/BdsSkeleton.stories.tsx +23 -23
- package/src/web-components/ui/BdsTable.mdx +81 -0
- package/src/web-components/ui/BdsTable.stories.tsx +83 -0
- package/src/web-components/ui/BdsTypography.mdx +20 -29
- package/src/web-components/ui/BdsTypography.stories.tsx +19 -18
- package/src/web-components/ui/bds-button-group.spec.ts +40 -0
- package/src/web-components/ui/bds-button-group.ts +78 -0
- package/src/web-components/ui/bds-calendar.spec.ts +91 -0
- package/src/web-components/ui/bds-calendar.ts +427 -0
- package/src/web-components/ui/bds-carousel.spec.ts +64 -0
- package/src/web-components/ui/bds-carousel.ts +296 -0
- package/src/web-components/ui/bds-pagination.spec.ts +67 -0
- package/src/web-components/ui/bds-pagination.ts +197 -0
- package/src/web-components/ui/bds-table.spec.ts +45 -0
- 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
|
+
});
|