@brandsync/wc 1.8.0 → 1.9.0

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 (100) hide show
  1. package/custom-elements.json +139 -11
  2. package/dist/brandsync-wc/brandsync-wc.esm.js +1 -1
  3. package/dist/brandsync-wc/p-0eed6813.entry.js +1 -0
  4. package/dist/brandsync-wc/{p-ee7da23d.entry.js → p-107b1c5d.entry.js} +1 -1
  5. package/dist/brandsync-wc/p-2181378f.entry.js +1 -0
  6. package/dist/brandsync-wc/p-27bbed83.entry.js +1 -0
  7. package/dist/brandsync-wc/p-36bf37d9.entry.js +1 -0
  8. package/dist/brandsync-wc/p-3b64e3cd.entry.js +1 -0
  9. package/dist/brandsync-wc/p-6fd7dd6a.entry.js +1 -0
  10. package/dist/brandsync-wc/p-9f6fe37d.entry.js +1 -0
  11. package/dist/brandsync-wc/{p-03b08508.entry.js → p-c3f72584.entry.js} +1 -1
  12. package/dist/brandsync-wc/p-fc240aa0.entry.js +1 -0
  13. package/dist/cjs/brandsync-wc.cjs.js +1 -1
  14. package/dist/cjs/bs-breadcrumb-overflow.cjs.entry.js +13 -1
  15. package/dist/cjs/bs-breadcrumb.cjs.entry.js +15 -1
  16. package/dist/cjs/bs-data-table.cjs.entry.js +18 -4
  17. package/dist/cjs/bs-icon-button.cjs.entry.js +2 -2
  18. package/dist/cjs/bs-inline-tab.cjs.entry.js +5 -1
  19. package/dist/cjs/bs-navigation-drawer-item.cjs.entry.js +1 -1
  20. package/dist/cjs/bs-navigation-drawer.cjs.entry.js +118 -5
  21. package/dist/cjs/bs-navigation-header.cjs.entry.js +1 -1
  22. package/dist/cjs/bs-tab.cjs.entry.js +5 -1
  23. package/dist/cjs/bs-tabs.cjs.entry.js +47 -2
  24. package/dist/cjs/loader.cjs.js +1 -1
  25. package/dist/collection/components/bs-breadcrumb/bs-breadcrumb/bs-breadcrumb.cmp.test.js +63 -0
  26. package/dist/collection/components/bs-breadcrumb/bs-breadcrumb/bs-breadcrumb.js +32 -1
  27. package/dist/collection/components/bs-breadcrumb/bs-breadcrumb-overflow/bs-breadcrumb-overflow.cmp.test.js +45 -0
  28. package/dist/collection/components/bs-breadcrumb/bs-breadcrumb-overflow/bs-breadcrumb-overflow.js +30 -1
  29. package/dist/collection/components/bs-button/bs-icon-button/bs-icon-button.cmp.test.js +20 -0
  30. package/dist/collection/components/bs-button/bs-icon-button/bs-icon-button.css +21 -4
  31. package/dist/collection/components/bs-button/bs-icon-button/bs-icon-button.js +3 -2
  32. package/dist/collection/components/bs-button/bs-icon-button/bs-icon-button.stories.js +10 -0
  33. package/dist/collection/components/bs-data-table/bs-data-table.cmp.test.js +19 -0
  34. package/dist/collection/components/bs-data-table/bs-data-table.js +19 -5
  35. package/dist/collection/components/bs-data-table/bs-data-table.stories.js +8 -1
  36. package/dist/collection/components/bs-logo/bs-logo.cmp.test.js +52 -3
  37. package/dist/collection/components/bs-menu/bs-menu.stories.js +54 -0
  38. package/dist/collection/components/bs-tab/bs-inline-tab/bs-inline-tab.js +24 -1
  39. package/dist/collection/components/bs-tab/bs-tab/bs-tab.js +24 -1
  40. package/dist/collection/components/bs-tab/bs-tabs/bs-tabs.cmp.test.js +88 -0
  41. package/dist/collection/components/bs-tab/bs-tabs/bs-tabs.js +83 -2
  42. package/dist/collection/components/bs-tab/bs-tabs/bs-tabs.stories.js +24 -0
  43. package/dist/collection/components/ui-shell/bs-navigation-drawer/bs-navigation-drawer/bs-navigation-drawer.cmp.test.js +173 -5
  44. package/dist/collection/components/ui-shell/bs-navigation-drawer/bs-navigation-drawer/bs-navigation-drawer.css +106 -34
  45. package/dist/collection/components/ui-shell/bs-navigation-drawer/bs-navigation-drawer/bs-navigation-drawer.js +181 -5
  46. package/dist/collection/components/ui-shell/bs-navigation-drawer/bs-navigation-drawer/bs-navigation-drawer.stories.js +48 -0
  47. package/dist/collection/components/ui-shell/bs-navigation-drawer/bs-navigation-drawer-item/bs-navigation-drawer-item.js +1 -1
  48. package/dist/collection/components/ui-shell/bs-navigation-header/bs-navigation-header/bs-navigation-header.cmp.test.js +68 -0
  49. package/dist/collection/components/ui-shell/bs-navigation-header/bs-navigation-header/bs-navigation-header.css +31 -17
  50. package/dist/components/bs-attachment.js +1 -1
  51. package/dist/components/bs-breadcrumb-overflow.js +1 -1
  52. package/dist/components/bs-breadcrumb.js +1 -1
  53. package/dist/components/bs-carousel.js +1 -1
  54. package/dist/components/bs-data-table.js +1 -1
  55. package/dist/components/bs-dialog.js +1 -1
  56. package/dist/components/bs-file-upload-item.js +1 -1
  57. package/dist/components/bs-icon-button.js +1 -1
  58. package/dist/components/bs-inline-tab.js +1 -1
  59. package/dist/components/bs-navigation-drawer-item.js +1 -1
  60. package/dist/components/bs-navigation-drawer.js +1 -1
  61. package/dist/components/bs-navigation-header.js +1 -1
  62. package/dist/components/bs-snackbar.js +1 -1
  63. package/dist/components/bs-tab.js +1 -1
  64. package/dist/components/bs-tabs.js +1 -1
  65. package/dist/components/p-BoeorHNv.js +1 -0
  66. package/dist/esm/brandsync-wc.js +1 -1
  67. package/dist/esm/bs-breadcrumb-overflow.entry.js +14 -2
  68. package/dist/esm/bs-breadcrumb.entry.js +16 -2
  69. package/dist/esm/bs-data-table.entry.js +18 -4
  70. package/dist/esm/bs-icon-button.entry.js +2 -2
  71. package/dist/esm/bs-inline-tab.entry.js +5 -1
  72. package/dist/esm/bs-navigation-drawer-item.entry.js +1 -1
  73. package/dist/esm/bs-navigation-drawer.entry.js +119 -6
  74. package/dist/esm/bs-navigation-header.entry.js +1 -1
  75. package/dist/esm/bs-tab.entry.js +5 -1
  76. package/dist/esm/bs-tabs.entry.js +48 -3
  77. package/dist/esm/loader.js +1 -1
  78. package/dist/types/components/bs-breadcrumb/bs-breadcrumb/bs-breadcrumb.d.ts +10 -0
  79. package/dist/types/components/bs-breadcrumb/bs-breadcrumb-overflow/bs-breadcrumb-overflow.d.ts +8 -0
  80. package/dist/types/components/bs-button/bs-icon-button/bs-icon-button.d.ts +2 -1
  81. package/dist/types/components/bs-button/bs-icon-button/bs-icon-button.stories.d.ts +1 -0
  82. package/dist/types/components/bs-data-table/bs-data-table.d.ts +16 -2
  83. package/dist/types/components/bs-menu/bs-menu.stories.d.ts +1 -0
  84. package/dist/types/components/bs-tab/bs-inline-tab/bs-inline-tab.d.ts +4 -0
  85. package/dist/types/components/bs-tab/bs-tab/bs-tab.d.ts +4 -0
  86. package/dist/types/components/bs-tab/bs-tabs/bs-tabs.d.ts +17 -0
  87. package/dist/types/components/bs-tab/bs-tabs/bs-tabs.stories.d.ts +1 -0
  88. package/dist/types/components/ui-shell/bs-navigation-drawer/bs-navigation-drawer/bs-navigation-drawer.d.ts +50 -1
  89. package/dist/types/components/ui-shell/bs-navigation-drawer/bs-navigation-drawer/bs-navigation-drawer.stories.d.ts +2 -0
  90. package/dist/types/components.d.ts +130 -2
  91. package/package.json +1 -1
  92. package/dist/brandsync-wc/p-002d258d.entry.js +0 -1
  93. package/dist/brandsync-wc/p-153da845.entry.js +0 -1
  94. package/dist/brandsync-wc/p-2e7a1082.entry.js +0 -1
  95. package/dist/brandsync-wc/p-612cf79c.entry.js +0 -1
  96. package/dist/brandsync-wc/p-652d4068.entry.js +0 -1
  97. package/dist/brandsync-wc/p-bc69a96c.entry.js +0 -1
  98. package/dist/brandsync-wc/p-bf53ad52.entry.js +0 -1
  99. package/dist/brandsync-wc/p-c7f4c114.entry.js +0 -1
  100. package/dist/components/p-BkHYJIyj.js +0 -1
@@ -44,11 +44,18 @@ export const WithCustomCellRenderer = {
44
44
  const table = document.createElement('bs-data-table');
45
45
  table.columns = [...columns, { key: 'actions', label: 'Actions' }];
46
46
  table.rows = rows;
47
+ // cellRenderer renders EVERY cell and its output is not escaped, so plain columns are escaped
48
+ // here too -- copying `String(row[column.key])` unescaped would make untrusted row data HTML.
49
+ const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
47
50
  table.cellRenderer = (row, column) => {
48
51
  if (column.key === 'actions') {
49
52
  return `<button type="button">Edit</button>`;
50
53
  }
51
- return String(row[column.key] ?? '');
54
+ if (column.key === 'status') {
55
+ // Rich content: a web component in the cell.
56
+ return `<bs-badge variant="${row.status === 'Active' ? 'success' : 'neutral'}">${escapeHtml(row.status)}</bs-badge>`;
57
+ }
58
+ return escapeHtml(row[column.key]);
52
59
  };
53
60
  return table;
54
61
  },
@@ -1,4 +1,5 @@
1
1
  import { render, h, describe, it, expect } from "@stencil/vitest";
2
+ import { vi } from "vitest";
2
3
  describe('bs-logo', () => {
3
4
  it('renders role="img" and aria-label="BrandSync"', async () => {
4
5
  const { root } = await render(h("bs-logo", null));
@@ -37,9 +38,9 @@ describe('bs-logo', () => {
37
38
  expect(getComputedStyle(darkAsset).display).toBe('none');
38
39
  });
39
40
  it('shows the dark asset and hides the light one when an ambient dark theme sets --bs-logo-asset-*-display', async () => {
40
- // Simulates what src/global/base.css's [data-theme="dark"] rule sets on <html> in a real
41
- // app -- setting the same custom properties directly on the render stage container here,
42
- // since this isolated component test doesn't load that global stylesheet. CSS custom
41
+ // Simulates what src/global/base.css's [data-theme="dark"] rule sets, by setting the same
42
+ // custom properties directly on the render stage container. (The real selector is exercised
43
+ // in the "follows the real [data-theme="dark"] attribute" tests below.) CSS custom
43
44
  // properties inherit through the shadow boundary regardless of where they're defined.
44
45
  const { root } = await render(h("bs-logo", null), {
45
46
  stageAttrs: { style: '--bs-logo-asset-light-display: none; --bs-logo-asset-dark-display: block;' },
@@ -50,6 +51,54 @@ describe('bs-logo', () => {
50
51
  expect(getComputedStyle(darkAsset).display).not.toBe('none');
51
52
  });
52
53
  });
54
+ // The tests above set the --bs-logo-asset-*-display variables by hand. These exercise the real
55
+ // `[data-theme="dark"]` rule from src/global/base.css (loaded by vitest-setup.ts), i.e. what a
56
+ // consuming app actually does -- including WHERE the attribute may sit (issue: dark logo on a dark
57
+ // theme).
58
+ describe('background="auto" follows the real [data-theme="dark"] attribute', () => {
59
+ const shown = (root) => ({
60
+ light: getComputedStyle(root.shadowRoot.querySelector('.bs-logo__asset--light')).display !== 'none',
61
+ dark: getComputedStyle(root.shadowRoot.querySelector('.bs-logo__asset--dark')).display !== 'none',
62
+ });
63
+ it('flips to the dark asset when data-theme="dark" is on <html>', async () => {
64
+ document.documentElement.setAttribute('data-theme', 'dark');
65
+ try {
66
+ const { root } = await render(h("bs-logo", null));
67
+ expect(shown(root)).toEqual({ light: false, dark: true });
68
+ }
69
+ finally {
70
+ document.documentElement.removeAttribute('data-theme');
71
+ }
72
+ });
73
+ it('flips to the dark asset when data-theme="dark" is on <body>', async () => {
74
+ document.body.setAttribute('data-theme', 'dark');
75
+ try {
76
+ const { root } = await render(h("bs-logo", null));
77
+ expect(shown(root)).toEqual({ light: false, dark: true });
78
+ }
79
+ finally {
80
+ document.body.removeAttribute('data-theme');
81
+ }
82
+ });
83
+ it('flips to the dark asset when data-theme="dark" is on any ancestor wrapper', async () => {
84
+ const { root } = await render(h("div", { "data-theme": "dark" }, h("bs-logo", null)));
85
+ expect(shown(root.querySelector('bs-logo'))).toEqual({ light: false, dark: true });
86
+ });
87
+ it('does NOT flip for a logo outside the data-theme="dark" element', async () => {
88
+ const { root } = await render(h("div", null, h("div", { "data-theme": "dark" }), h("bs-logo", null)));
89
+ expect(shown(root.querySelector('bs-logo'))).toEqual({ light: true, dark: false });
90
+ });
91
+ it('does not follow prefers-color-scheme (only the data-theme attribute)', async () => {
92
+ const spy = vi.spyOn(window, 'matchMedia').mockImplementation((q) => ({ matches: q.includes('prefers-color-scheme: dark'), media: q, addEventListener: () => undefined, removeEventListener: () => undefined, addListener: () => undefined, removeListener: () => undefined, onchange: null, dispatchEvent: () => false }));
93
+ try {
94
+ const { root } = await render(h("bs-logo", null));
95
+ expect(shown(root)).toEqual({ light: true, dark: false });
96
+ }
97
+ finally {
98
+ spy.mockRestore();
99
+ }
100
+ });
101
+ });
53
102
  describe('background="light"/"dark" (explicit)', () => {
54
103
  it('pins the light asset even when an ambient dark theme is set', async () => {
55
104
  const { root } = await render(h("bs-logo", { background: "light" }), {
@@ -13,3 +13,57 @@ const meta = {
13
13
  };
14
14
  export default meta;
15
15
  export const Default = {};
16
+ // `bs-menu` is only the popup surface: it does not open, close or position itself. This story is the
17
+ // recipe for turning it into a dropdown with the pieces the library already has (a button, the menu,
18
+ // and ~25 lines of wiring). The same code is shown in bs-menu.mdx.
19
+ export const DropdownRecipe = {
20
+ name: 'Dropdown (recipe)',
21
+ render: () => {
22
+ const host = document.createElement('div');
23
+ host.style.minHeight = '220px';
24
+ host.innerHTML = `
25
+ <div class="dropdown" style="position: relative; display: inline-block;">
26
+ <bs-button aria-haspopup="menu" aria-expanded="false">Actions</bs-button>
27
+ <bs-menu style="display: none; position: absolute; top: calc(100% + var(--bs-spacing-50)); inset-inline-start: 0; z-index: var(--bs-z-index-popover); width: 210px;">
28
+ <bs-menu-item>Edit</bs-menu-item>
29
+ <bs-menu-item>Duplicate</bs-menu-item>
30
+ <bs-menu-item disabled>Archive</bs-menu-item>
31
+ <bs-menu-item>Delete</bs-menu-item>
32
+ </bs-menu>
33
+ </div>`;
34
+ const wrapper = host.querySelector('.dropdown');
35
+ const trigger = wrapper.querySelector('bs-button');
36
+ const menu = wrapper.querySelector('bs-menu');
37
+ const onDocumentClick = (ev) => {
38
+ // composedPath() so a click inside the shadow DOM of the button / menu items counts as inside
39
+ if (!ev.composedPath().includes(wrapper))
40
+ setOpen(false);
41
+ };
42
+ function setOpen(open, restoreFocus = false) {
43
+ menu.style.display = open ? 'block' : 'none';
44
+ trigger.setAttribute('aria-expanded', String(open));
45
+ if (open) {
46
+ document.addEventListener('click', onDocumentClick);
47
+ // bs-menu handles Up/Down/Home/End once focus is inside; put it on the first enabled item
48
+ menu.querySelector('bs-menu-item:not([disabled])')?.shadowRoot?.querySelector('button')?.focus();
49
+ }
50
+ else {
51
+ document.removeEventListener('click', onDocumentClick);
52
+ if (restoreFocus)
53
+ trigger.focus();
54
+ }
55
+ }
56
+ trigger.addEventListener('click', () => setOpen(menu.style.display === 'none'));
57
+ wrapper.addEventListener('keydown', (ev) => {
58
+ if (ev.key === 'Escape')
59
+ setOpen(false, true);
60
+ });
61
+ wrapper.addEventListener('focusout', (ev) => {
62
+ // Tab (or anything else) moving focus out of the whole dropdown closes it
63
+ if (menu.style.display !== 'none' && !wrapper.contains(ev.relatedTarget))
64
+ setOpen(false);
65
+ });
66
+ menu.addEventListener('bsSelect', () => setOpen(false, true));
67
+ return host;
68
+ },
69
+ };
@@ -48,6 +48,10 @@ export class BsInlineTab {
48
48
  * violation). Stencil reflects this camelCase prop to the `aria-label` HTML attribute
49
49
  * automatically, same as `bs-tab`'s identical prop. */
50
50
  ariaLabel = null;
51
+ /** Identifies this tab within a parent `bs-tabs`: the group's `value` is the selected tab's
52
+ * `value`, and `bsChange` reports it. Optional -- a tab without one is identified by its
53
+ * zero-based position in the group (`"0"`, `"1"`...). */
54
+ value;
51
55
  /** Set by a parent `bs-tabs` to implement roving tabindex (only one tab in the group is in the
52
56
  * Tab order; arrow keys move between them). Left `undefined` for a standalone tab, which stays
53
57
  * a normal tab stop. */
@@ -90,7 +94,7 @@ export class BsInlineTab {
90
94
  };
91
95
  render() {
92
96
  const iconOnly = this.hasIcon && !this.hasLabel;
93
- return (h("button", { key: '72a2298b0dff31e192dc679ee153d1d87e6a8723', type: "button", part: "tab", role: "tab", class: `bs-inline-tab ${this.selected ? 'bs-inline-tab--selected' : ''} ${this.disabled ? 'bs-inline-tab--disabled' : ''} ${iconOnly ? 'bs-inline-tab--icon-only' : ''} ${this.hasIcon ? 'bs-inline-tab--has-icon' : ''}`, disabled: this.disabled, "aria-selected": String(this.selected), "aria-disabled": this.disabled ? 'true' : undefined, "aria-label": this.ariaLabel, tabIndex: this.tabbable === undefined ? undefined : this.tabbable ? 0 : -1, onClick: this.onClick }, h("span", { key: '25679b5cfc3bfec29f91b12f6180048429706b86', part: "icon", class: `bs-inline-tab__icon ${this.hasIcon ? 'bs-inline-tab__icon--has-content' : ''}` }, h("slot", { key: 'f5c996ae38a4e5ace60c8b1145dfb64114097600', name: "icon", onSlotchange: this.onIconSlotchange })), h("span", { key: '101ac663fa1b05e3f4f2faaaff865e02ecbd4e90', part: "label", class: `bs-inline-tab__label ${this.hasLabel ? 'bs-inline-tab__label--has-content' : ''}` }, h("slot", { key: 'a705183b26ad7d7ad3e286939b256cfbafb78580', onSlotchange: this.onLabelSlotchange }))));
97
+ return (h("button", { key: '0d6927b0b39a7d84add287e6473681e5052b5f2d', type: "button", part: "tab", role: "tab", class: `bs-inline-tab ${this.selected ? 'bs-inline-tab--selected' : ''} ${this.disabled ? 'bs-inline-tab--disabled' : ''} ${iconOnly ? 'bs-inline-tab--icon-only' : ''} ${this.hasIcon ? 'bs-inline-tab--has-icon' : ''}`, disabled: this.disabled, "aria-selected": String(this.selected), "aria-disabled": this.disabled ? 'true' : undefined, "aria-label": this.ariaLabel, tabIndex: this.tabbable === undefined ? undefined : this.tabbable ? 0 : -1, onClick: this.onClick }, h("span", { key: '866660f0f79927cd018df42286719f2f48262cbb', part: "icon", class: `bs-inline-tab__icon ${this.hasIcon ? 'bs-inline-tab__icon--has-content' : ''}` }, h("slot", { key: '58e11c8a0a57646644cb0b7c950d73f5cee52310', name: "icon", onSlotchange: this.onIconSlotchange })), h("span", { key: '38d2e1df2b4e494f68d3c4b9ea34d7a317401953', part: "label", class: `bs-inline-tab__label ${this.hasLabel ? 'bs-inline-tab__label--has-content' : ''}` }, h("slot", { key: '51fb6ec04479b18af787bca4e64f8105a0b19311', onSlotchange: this.onLabelSlotchange }))));
94
98
  }
95
99
  static get is() { return "bs-inline-tab"; }
96
100
  static get encapsulation() { return "shadow"; }
@@ -166,6 +170,25 @@ export class BsInlineTab {
166
170
  "attribute": "aria-label",
167
171
  "defaultValue": "null"
168
172
  },
173
+ "value": {
174
+ "type": "string",
175
+ "mutable": false,
176
+ "complexType": {
177
+ "original": "string",
178
+ "resolved": "string",
179
+ "references": {}
180
+ },
181
+ "required": false,
182
+ "optional": true,
183
+ "docs": {
184
+ "tags": [],
185
+ "text": "Identifies this tab within a parent `bs-tabs`: the group's `value` is the selected tab's\n`value`, and `bsChange` reports it. Optional -- a tab without one is identified by its\nzero-based position in the group (`\"0\"`, `\"1\"`...)."
186
+ },
187
+ "getter": false,
188
+ "setter": false,
189
+ "reflect": true,
190
+ "attribute": "value"
191
+ },
169
192
  "tabbable": {
170
193
  "type": "boolean",
171
194
  "mutable": false,
@@ -53,6 +53,10 @@ export class BsTab {
53
53
  * violation). Stencil reflects this camelCase prop to the `aria-label` HTML attribute
54
54
  * automatically, same as `bs-button`'s identical prop. */
55
55
  ariaLabel = null;
56
+ /** Identifies this tab within a parent `bs-tabs`: the group's `value` is the selected tab's
57
+ * `value`, and `bsChange` reports it. Optional -- a tab without one is identified by its
58
+ * zero-based position in the group (`"0"`, `"1"`...). */
59
+ value;
56
60
  /** Set by a parent `bs-tabs` to implement roving tabindex (only one tab in the group is in the
57
61
  * Tab order; arrow keys move between them). Left `undefined` for a standalone tab, which stays
58
62
  * a normal tab stop. */
@@ -103,7 +107,7 @@ export class BsTab {
103
107
  const iconOnly = this.hasIcon && !this.hasLabel;
104
108
  const vertical = this.hasIcon && this.hasLabel && this.iconPosition === 'top';
105
109
  const showBadge = this.hasBadge && !iconOnly && !vertical;
106
- return (h("button", { key: '368a2f20536d169e06e47500dcca4d6a54f17dde', type: "button", part: "tab", role: "tab", class: `bs-tab ${this.selected ? 'bs-tab--selected' : ''} ${this.disabled ? 'bs-tab--disabled' : ''} ${iconOnly ? 'bs-tab--icon-only' : ''} ${vertical ? 'bs-tab--vertical' : ''}`, disabled: this.disabled, "aria-selected": String(this.selected), "aria-disabled": this.disabled ? 'true' : undefined, "aria-label": this.ariaLabel, tabIndex: this.tabbable === undefined ? undefined : this.tabbable ? 0 : -1, onClick: this.onClick }, h("span", { key: '3b22e92a5d6bca27c0b12b6f80ef29af06bcc564', part: "icon", class: `bs-tab__icon ${this.hasIcon ? 'bs-tab__icon--has-content' : ''}` }, h("slot", { key: 'd75cb8cc85547a8c6a8be3bca3fc82a61d3cfa38', name: "icon", onSlotchange: this.onIconSlotchange })), h("span", { key: '43ff4feb2425f1b0dd9485f323b1886b42e10a32', part: "label", class: `bs-tab__label ${this.hasLabel ? 'bs-tab__label--has-content' : ''}` }, h("slot", { key: '74a0af9a35043faa3baa2415c3b4a204f5745155', onSlotchange: this.onLabelSlotchange })), h("span", { key: '22b5c4671b7a7c93abffae5a34a4e3bb600d8ed3', part: "badge", class: `bs-tab__badge ${showBadge ? 'bs-tab__badge--has-content' : ''}` }, h("slot", { key: '17b123826ca4b78ff00b89d3a0944e1d805ad4fe', name: "badge", onSlotchange: this.onBadgeSlotchange })), h("span", { key: 'd51f23caa9d0fd0abddd37c7394994e1ffef0586', part: "indicator", class: "bs-tab__indicator" })));
110
+ return (h("button", { key: '383cf44dfcf1feb5404e6a392318c8ce5a8a1178', type: "button", part: "tab", role: "tab", class: `bs-tab ${this.selected ? 'bs-tab--selected' : ''} ${this.disabled ? 'bs-tab--disabled' : ''} ${iconOnly ? 'bs-tab--icon-only' : ''} ${vertical ? 'bs-tab--vertical' : ''}`, disabled: this.disabled, "aria-selected": String(this.selected), "aria-disabled": this.disabled ? 'true' : undefined, "aria-label": this.ariaLabel, tabIndex: this.tabbable === undefined ? undefined : this.tabbable ? 0 : -1, onClick: this.onClick }, h("span", { key: '8b3a6b9ee663c2957c206024478ee76abf4fb905', part: "icon", class: `bs-tab__icon ${this.hasIcon ? 'bs-tab__icon--has-content' : ''}` }, h("slot", { key: 'b46fd73bac532345e747bb214f8c91f32b04810c', name: "icon", onSlotchange: this.onIconSlotchange })), h("span", { key: '9d3e69f4dc96e20a327ebccdbdf934f07a4892c0', part: "label", class: `bs-tab__label ${this.hasLabel ? 'bs-tab__label--has-content' : ''}` }, h("slot", { key: 'e724d44bec76845b64e8e0ebb5c883dc47ff11a3', onSlotchange: this.onLabelSlotchange })), h("span", { key: '1cb4e5a8056ebac8b1b098c350c4a5d89bd2968a', part: "badge", class: `bs-tab__badge ${showBadge ? 'bs-tab__badge--has-content' : ''}` }, h("slot", { key: '46a6bbf2c8702645b85062012294eedf3facceac', name: "badge", onSlotchange: this.onBadgeSlotchange })), h("span", { key: '38845ccafeddfe79af64c3e291197e88bbced150', part: "indicator", class: "bs-tab__indicator" })));
107
111
  }
108
112
  static get is() { return "bs-tab"; }
109
113
  static get encapsulation() { return "shadow"; }
@@ -205,6 +209,25 @@ export class BsTab {
205
209
  "attribute": "aria-label",
206
210
  "defaultValue": "null"
207
211
  },
212
+ "value": {
213
+ "type": "string",
214
+ "mutable": false,
215
+ "complexType": {
216
+ "original": "string",
217
+ "resolved": "string",
218
+ "references": {}
219
+ },
220
+ "required": false,
221
+ "optional": true,
222
+ "docs": {
223
+ "tags": [],
224
+ "text": "Identifies this tab within a parent `bs-tabs`: the group's `value` is the selected tab's\n`value`, and `bsChange` reports it. Optional -- a tab without one is identified by its\nzero-based position in the group (`\"0\"`, `\"1\"`...)."
225
+ },
226
+ "getter": false,
227
+ "setter": false,
228
+ "reflect": true,
229
+ "attribute": "value"
230
+ },
208
231
  "tabbable": {
209
232
  "type": "boolean",
210
233
  "mutable": false,
@@ -58,4 +58,92 @@ describe('bs-tabs', () => {
58
58
  expect(first.selected).toBe(true);
59
59
  expect(second.selected).toBe(false);
60
60
  });
61
+ describe('value and bsChange', () => {
62
+ const btn = (el) => el.shadowRoot.querySelector('button');
63
+ const key = (el, k) => el.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, composed: true, cancelable: true }));
64
+ const group = () => (h("bs-tabs", null, h("bs-tab", { value: "overview" }, "Overview"), h("bs-tab", { value: "details" }, "Details"), h("bs-tab", { value: "history" }, "History")));
65
+ it('a click selects the tab, updates the group value and emits bsChange with it', async () => {
66
+ const { root, waitForChanges, spyOnEvent } = await render(group());
67
+ const spy = spyOnEvent('bsChange');
68
+ const tabs = Array.from(root.querySelectorAll('bs-tab'));
69
+ btn(tabs[1]).click();
70
+ await waitForChanges();
71
+ expect(root.value).toBe('details');
72
+ expect(root.getAttribute('value')).toBe('details'); // reflected
73
+ expect(tabs[1].selected).toBe(true);
74
+ expect(spy).toHaveReceivedEventTimes(1);
75
+ expect(spy).toHaveReceivedEventDetail({ value: 'details' });
76
+ });
77
+ it('does not emit again when the already-selected tab is clicked', async () => {
78
+ const { root, waitForChanges, spyOnEvent } = await render(group());
79
+ const spy = spyOnEvent('bsChange');
80
+ const tabs = Array.from(root.querySelectorAll('bs-tab'));
81
+ btn(tabs[1]).click();
82
+ await waitForChanges();
83
+ btn(tabs[1]).click();
84
+ await waitForChanges();
85
+ expect(spy).toHaveReceivedEventTimes(1);
86
+ });
87
+ it('a tab without a value is identified by its zero-based index as a string', async () => {
88
+ const { root, waitForChanges, spyOnEvent } = await render(h("bs-tabs", null, h("bs-tab", null, "One"), h("bs-tab", null, "Two")));
89
+ const spy = spyOnEvent('bsChange');
90
+ btn(root.querySelectorAll('bs-tab')[1]).click();
91
+ await waitForChanges();
92
+ expect(spy).toHaveReceivedEventDetail({ value: '1' });
93
+ expect(root.value).toBe('1');
94
+ });
95
+ it('an initial value selects the matching tab on load', async () => {
96
+ const { root, waitForChanges } = await render(h("bs-tabs", { value: "history" }, h("bs-tab", { value: "overview" }, "Overview"), h("bs-tab", { value: "details" }, "Details"), h("bs-tab", { value: "history" }, "History")));
97
+ await waitForChanges();
98
+ const tabs = Array.from(root.querySelectorAll('bs-tab'));
99
+ expect(tabs.map(t => t.selected)).toEqual([false, false, true]);
100
+ // roving tabindex follows the selection
101
+ expect(btn(tabs[2]).tabIndex).toBe(0);
102
+ expect(btn(tabs[0]).tabIndex).toBe(-1);
103
+ });
104
+ it('setting value from outside selects the matching tab and does NOT emit bsChange', async () => {
105
+ const { root, waitForChanges, spyOnEvent } = await render(group());
106
+ const spy = spyOnEvent('bsChange');
107
+ root.value = 'details';
108
+ await waitForChanges();
109
+ const tabs = Array.from(root.querySelectorAll('bs-tab'));
110
+ expect(tabs.map(t => t.selected)).toEqual([false, true, false]);
111
+ expect(spy).not.toHaveReceivedEvent();
112
+ });
113
+ it('with no value set, reports the tab the consumer already marked selected (without emitting)', async () => {
114
+ const { root, waitForChanges, spyOnEvent } = await render(h("bs-tabs", null, h("bs-tab", { value: "a" }, "A"), h("bs-tab", { value: "b", selected: true }, "B")));
115
+ const spy = spyOnEvent('bsChange');
116
+ await waitForChanges();
117
+ expect(root.value).toBe('b');
118
+ expect(spy).not.toHaveReceivedEvent();
119
+ });
120
+ it('keyboard navigation emits bsChange too (arrow keys select the next tab)', async () => {
121
+ const { root, waitForChanges, spyOnEvent } = await render(group());
122
+ const spy = spyOnEvent('bsChange');
123
+ const tabs = Array.from(root.querySelectorAll('bs-tab'));
124
+ btn(tabs[0]).click();
125
+ await waitForChanges();
126
+ key(btn(tabs[0]), 'ArrowRight');
127
+ await waitForChanges();
128
+ expect(spy).toHaveReceivedEventDetail({ value: 'details' });
129
+ });
130
+ it('still emits bsSelect on the tab itself', async () => {
131
+ const { root, waitForChanges, spyOnEvent } = await render(group());
132
+ const spy = spyOnEvent('bsSelect');
133
+ btn(root.querySelectorAll('bs-tab')[2]).click();
134
+ await waitForChanges();
135
+ expect(spy).toHaveReceivedEventTimes(1);
136
+ });
137
+ it('works for bs-inline-tab groups', async () => {
138
+ const { root, waitForChanges, spyOnEvent } = await render(h("bs-tabs", { type: "bs-inline-tab" }, h("bs-inline-tab", { value: "x" }, "X"), h("bs-inline-tab", { value: "y" }, "Y")));
139
+ const spy = spyOnEvent('bsChange');
140
+ btn(root.querySelectorAll('bs-inline-tab')[1]).click();
141
+ await waitForChanges();
142
+ expect(spy).toHaveReceivedEventDetail({ value: 'y' });
143
+ });
144
+ it('a tab\'s value prop is reflected as an attribute', async () => {
145
+ const { root } = await render(group());
146
+ expect(root.querySelector('bs-tab').getAttribute('value')).toBe('overview');
147
+ });
148
+ });
61
149
  });
@@ -35,6 +35,16 @@ export class BsTabs {
35
35
  /** Layout direction of the tab bar itself. Sets `aria-orientation` on the internal wrapper and
36
36
  * switches `flex-direction`. */
37
37
  orientation = 'horizontal';
38
+ /** The selected tab's `value`. Mutable and reflected: it follows the user's selection, and
39
+ * setting it from outside (attribute or property) selects the matching tab -- without emitting
40
+ * `bsChange`, which only fires for user-driven changes. A tab's value is its own `value` prop,
41
+ * or, for a tab without one, its zero-based position in the group as a string (`"0"`, `"1"`...).
42
+ * If unset, it is read from whichever tab is already `selected` once the group loads. */
43
+ value;
44
+ /** Fires when the user selects a different tab (click, or arrow/Home/End), with the new
45
+ * `value`. Does not fire when `value` is set programmatically. `bsSelect` on the individual tab
46
+ * still fires as before. */
47
+ bsChange;
38
48
  /**
39
49
  * Catches `bsSelect` bubbling up from any slotted `<bs-tab>`/`<bs-inline-tab>` (Stencil's
40
50
  * `@Event()` defaults to `bubbles: true, composed: true`, and `@Listen` without a target listens
@@ -43,15 +53,46 @@ export class BsTabs {
43
53
  * `if (this.disabled) return;`), so no special-casing is needed here.
44
54
  */
45
55
  onTabSelect(ev) {
46
- const tabs = this.el.querySelectorAll(':scope > bs-tab, :scope > bs-inline-tab');
56
+ const tabs = this.tabs();
47
57
  tabs.forEach(tab => {
48
58
  tab.selected = tab === ev.target;
49
59
  });
50
60
  this.syncRovingTabindex();
61
+ const index = tabs.findIndex(tab => tab === ev.target);
62
+ if (index === -1)
63
+ return;
64
+ const value = this.tabValue(tabs[index], index);
65
+ if (value !== this.value) {
66
+ this.value = value;
67
+ this.bsChange.emit({ value });
68
+ }
51
69
  }
52
70
  componentDidLoad() {
71
+ if (this.value !== undefined) {
72
+ this.applyValue();
73
+ }
74
+ else {
75
+ // Uncontrolled: report whichever tab the consumer already marked `selected`.
76
+ const tabs = this.tabs();
77
+ const index = tabs.findIndex(t => t.selected);
78
+ if (index !== -1)
79
+ this.value = this.tabValue(tabs[index], index);
80
+ }
53
81
  this.syncRovingTabindex();
54
82
  }
83
+ tabValue(tab, index) {
84
+ return tab.value ?? String(index);
85
+ }
86
+ /** Selects the tab whose value matches `this.value` (and deselects the rest). */
87
+ applyValue() {
88
+ this.tabs().forEach((tab, i) => {
89
+ tab.selected = this.tabValue(tab, i) === this.value;
90
+ });
91
+ this.syncRovingTabindex();
92
+ }
93
+ onValueChange() {
94
+ this.applyValue();
95
+ }
55
96
  syncRovingTabindex() {
56
97
  const tabs = this.tabs();
57
98
  const anySelected = tabs.some(t => t.selected);
@@ -94,7 +135,7 @@ export class BsTabs {
94
135
  }
95
136
  render() {
96
137
  const vertical = this.orientation === 'vertical';
97
- return (h("div", { key: '81b3b46d54d4acc074b0c59d0da5e3e971e7b62e', part: "tablist", role: "tablist", "aria-orientation": this.orientation, class: `bs-tabs bs-tabs--${this.type} ${vertical ? 'bs-tabs--vertical' : ''}` }, h("slot", { key: 'ba296a99062629de3e331cd7de1ebc41548c77f9', onSlotchange: () => this.syncRovingTabindex() })));
138
+ return (h("div", { key: '251521d36dcf4d6960df344285bc9a060ff87abb', part: "tablist", role: "tablist", "aria-orientation": this.orientation, class: `bs-tabs bs-tabs--${this.type} ${vertical ? 'bs-tabs--vertical' : ''}` }, h("slot", { key: '02216f048eaedb13f00891c30e1c49d15a509c1d', onSlotchange: () => this.syncRovingTabindex() })));
98
139
  }
99
140
  static get is() { return "bs-tabs"; }
100
141
  static get encapsulation() { return "shadow"; }
@@ -161,12 +202,52 @@ export class BsTabs {
161
202
  "reflect": false,
162
203
  "attribute": "orientation",
163
204
  "defaultValue": "'horizontal'"
205
+ },
206
+ "value": {
207
+ "type": "string",
208
+ "mutable": true,
209
+ "complexType": {
210
+ "original": "string",
211
+ "resolved": "string",
212
+ "references": {}
213
+ },
214
+ "required": false,
215
+ "optional": true,
216
+ "docs": {
217
+ "tags": [],
218
+ "text": "The selected tab's `value`. Mutable and reflected: it follows the user's selection, and\nsetting it from outside (attribute or property) selects the matching tab -- without emitting\n`bsChange`, which only fires for user-driven changes. A tab's value is its own `value` prop,\nor, for a tab without one, its zero-based position in the group as a string (`\"0\"`, `\"1\"`...).\nIf unset, it is read from whichever tab is already `selected` once the group loads."
219
+ },
220
+ "getter": false,
221
+ "setter": false,
222
+ "reflect": true,
223
+ "attribute": "value"
164
224
  }
165
225
  };
166
226
  }
227
+ static get events() {
228
+ return [{
229
+ "method": "bsChange",
230
+ "name": "bsChange",
231
+ "bubbles": true,
232
+ "cancelable": true,
233
+ "composed": true,
234
+ "docs": {
235
+ "tags": [],
236
+ "text": "Fires when the user selects a different tab (click, or arrow/Home/End), with the new\n`value`. Does not fire when `value` is set programmatically. `bsSelect` on the individual tab\nstill fires as before."
237
+ },
238
+ "complexType": {
239
+ "original": "{ value: string }",
240
+ "resolved": "{ value: string; }",
241
+ "references": {}
242
+ }
243
+ }];
244
+ }
167
245
  static get elementRef() { return "el"; }
168
246
  static get watchers() {
169
247
  return [{
248
+ "propName": "value",
249
+ "methodName": "onValueChange"
250
+ }, {
170
251
  "propName": "orientation",
171
252
  "methodName": "syncRovingTabindex"
172
253
  }];
@@ -37,3 +37,27 @@ export const InlineTabGroup = {
37
37
  export const Vertical = {
38
38
  args: { orientation: 'vertical' },
39
39
  };
40
+ // The group's `value` follows the selection, and `bsChange` reports user-driven changes: the panel
41
+ // below switches in response to it, with no per-tab selection wiring.
42
+ export const WithValueAndPanels = {
43
+ name: 'With value and panels',
44
+ render: () => {
45
+ const panels = {
46
+ overview: 'Overview content',
47
+ details: 'Details content',
48
+ history: 'History content',
49
+ };
50
+ const onChange = (ev) => {
51
+ const panel = ev.currentTarget.nextElementSibling;
52
+ panel.textContent = panels[ev.detail.value];
53
+ };
54
+ return html `
55
+ <bs-tabs value="overview" @bsChange=${onChange}>
56
+ <bs-tab value="overview">Overview</bs-tab>
57
+ <bs-tab value="details">Details</bs-tab>
58
+ <bs-tab value="history">History</bs-tab>
59
+ </bs-tabs>
60
+ <p role="tabpanel" style="margin: 16px 0 0;">Overview content</p>
61
+ `;
62
+ },
63
+ };