@ds-mo/ui 1.0.2 → 1.1.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 (177) hide show
  1. package/dist/components/ds-bar-nav-action.js +1 -1
  2. package/dist/components/ds-bar-nav.js +1 -1
  3. package/dist/components/ds-bar-nav.js.map +1 -1
  4. package/dist/components/ds-breadcrumb.js +1 -1
  5. package/dist/components/ds-button-group.js +1 -1
  6. package/dist/components/ds-button.js +1 -1
  7. package/dist/components/ds-card.js +1 -1
  8. package/dist/components/ds-card.js.map +1 -1
  9. package/dist/components/ds-checkbox.js +1 -1
  10. package/dist/components/ds-divider.js +1 -1
  11. package/dist/components/ds-empty-state.js +1 -1
  12. package/dist/components/ds-empty-state.js.map +1 -1
  13. package/dist/components/ds-fade.js +1 -1
  14. package/dist/components/ds-field.js +1 -1
  15. package/dist/components/ds-header.js +1 -1
  16. package/dist/components/ds-header.js.map +1 -1
  17. package/dist/components/ds-icon.js +1 -1
  18. package/dist/components/ds-input.js +1 -1
  19. package/dist/components/ds-label-wrap.js +1 -1
  20. package/dist/components/ds-menu.js +1 -1
  21. package/dist/components/ds-modal.js +1 -1
  22. package/dist/components/ds-panel-nav.js +1 -1
  23. package/dist/components/ds-radio-group.js +1 -1
  24. package/dist/components/ds-radio-group.js.map +1 -1
  25. package/dist/components/ds-scrollbar.js +1 -1
  26. package/dist/components/ds-select.js +1 -1
  27. package/dist/components/ds-slider.js +1 -1
  28. package/dist/components/ds-slider.js.map +1 -1
  29. package/dist/components/ds-surface.js +1 -1
  30. package/dist/components/ds-tab-group-nav.d.ts +11 -0
  31. package/dist/components/ds-tab-group-nav.js +2 -0
  32. package/dist/components/ds-tab-group-nav.js.map +1 -0
  33. package/dist/components/ds-tab-group.js +1 -1
  34. package/dist/components/ds-tab-group.js.map +1 -1
  35. package/dist/components/ds-table.js +1 -1
  36. package/dist/components/ds-table.js.map +1 -1
  37. package/dist/components/ds-tag.js +1 -1
  38. package/dist/components/ds-text.js +1 -1
  39. package/dist/components/ds-toast-provider.js +1 -1
  40. package/dist/components/ds-toggle-button-group.js +1 -1
  41. package/dist/components/ds-toggle-button.js +1 -1
  42. package/dist/components/ds-toggle.js +1 -1
  43. package/dist/components/ds-tooltip.js +1 -1
  44. package/dist/components/p-BQq26pt9.js +2 -0
  45. package/dist/components/p-BQq26pt9.js.map +1 -0
  46. package/dist/components/p-CMyQsk0K.js +2 -0
  47. package/dist/components/p-CMyQsk0K.js.map +1 -0
  48. package/dist/components/{p-D3CwGoIu.js → p-COsKOwZR.js} +2 -2
  49. package/dist/components/p-CpbnmEu1.js +2 -0
  50. package/dist/components/p-CpbnmEu1.js.map +1 -0
  51. package/dist/components/{p-DouEVPmi.js → p-Dd0GrKaF.js} +2 -2
  52. package/dist/components/{p-BvxKLqNx.js → p-H89PzqGU.js} +2 -2
  53. package/dist/components/{p-BYe8Rfdg.js → p-Vwlmhl4E.js} +2 -2
  54. package/dist/components/{p-C4p1fG67.js → p-woooYulR.js} +2 -2
  55. package/dist/types/.stencil/BarNav/BarNav.d.ts +7 -2
  56. package/dist/types/.stencil/BarNav/bar-nav-dom-utils.d.ts +1 -2
  57. package/dist/types/.stencil/TabGroupNav/TabGroupNav.d.ts +27 -0
  58. package/dist/types/components.d.ts +68 -0
  59. package/package.json +37 -7
  60. package/src/angular/index.ts +1 -0
  61. package/src/angular/proxies.ts +28 -0
  62. package/src/react/components.ts +46 -0
  63. package/src/react/ds-accordion.ts +27 -0
  64. package/src/react/ds-badge.ts +26 -0
  65. package/src/react/ds-banner.ts +27 -0
  66. package/src/react/ds-bar-nav-action.ts +27 -0
  67. package/src/react/ds-bar-nav.ts +33 -0
  68. package/src/react/ds-breadcrumb.ts +27 -0
  69. package/src/react/ds-button-group.ts +27 -0
  70. package/src/react/ds-button.ts +35 -0
  71. package/src/react/ds-card.ts +26 -0
  72. package/src/react/ds-checkbox.ts +27 -0
  73. package/src/react/ds-divider.ts +26 -0
  74. package/src/react/ds-empty-state.ts +26 -0
  75. package/src/react/ds-fade.ts +26 -0
  76. package/src/react/ds-field.ts +26 -0
  77. package/src/react/ds-header.ts +26 -0
  78. package/src/react/ds-icon.ts +26 -0
  79. package/src/react/ds-input.ts +33 -0
  80. package/src/react/ds-label-wrap.ts +26 -0
  81. package/src/react/ds-loader.ts +26 -0
  82. package/src/react/ds-menu.ts +33 -0
  83. package/src/react/ds-modal.ts +27 -0
  84. package/src/react/ds-pagination.ts +27 -0
  85. package/src/react/ds-panel-nav.ts +37 -0
  86. package/src/react/ds-radio-group.ts +27 -0
  87. package/src/react/ds-scrollbar.ts +26 -0
  88. package/src/react/ds-select.ts +27 -0
  89. package/src/react/ds-sidebar.ts +33 -0
  90. package/src/react/ds-skeleton.ts +26 -0
  91. package/src/react/ds-slider.ts +27 -0
  92. package/src/react/ds-surface.ts +26 -0
  93. package/src/react/ds-tab-group-nav.ts +27 -0
  94. package/src/react/ds-tab-group.ts +27 -0
  95. package/src/react/ds-table.ts +35 -0
  96. package/src/react/ds-tag.ts +35 -0
  97. package/src/react/ds-text.ts +26 -0
  98. package/src/react/ds-toast-provider.ts +26 -0
  99. package/src/react/ds-toggle-button-group.ts +27 -0
  100. package/src/react/ds-toggle-button.ts +27 -0
  101. package/src/react/ds-toggle.ts +27 -0
  102. package/src/react/ds-tooltip.ts +26 -0
  103. package/src/wc/components/Accordion/Accordion.tsx +144 -0
  104. package/src/wc/components/Badge/Badge.tsx +34 -0
  105. package/src/wc/components/Badge/index.ts +1 -0
  106. package/src/wc/components/Banner/Banner.tsx +148 -0
  107. package/src/wc/components/BarNav/BarNav.tsx +605 -0
  108. package/src/wc/components/BarNav/bar-nav-dom-utils.ts +23 -0
  109. package/src/wc/components/BarNav/bar-nav-tabs-menu-utils.ts +59 -0
  110. package/src/wc/components/BarNav/bar-nav-utils.ts +76 -0
  111. package/src/wc/components/BarNav/index.ts +1 -0
  112. package/src/wc/components/BarNavAction/BarNavAction.tsx +67 -0
  113. package/src/wc/components/BarNavAction/index.ts +1 -0
  114. package/src/wc/components/Breadcrumb/Breadcrumb.tsx +61 -0
  115. package/src/wc/components/Button/Button.tsx +158 -0
  116. package/src/wc/components/Button/index.ts +1 -0
  117. package/src/wc/components/ButtonGroup/ButtonGroup.tsx +110 -0
  118. package/src/wc/components/Card/Card.tsx +26 -0
  119. package/src/wc/components/Checkbox/Checkbox.tsx +60 -0
  120. package/src/wc/components/Checkbox/index.ts +1 -0
  121. package/src/wc/components/Divider/Divider.tsx +23 -0
  122. package/src/wc/components/Divider/index.ts +2 -0
  123. package/src/wc/components/EmptyState/EmptyState.tsx +32 -0
  124. package/src/wc/components/Fade/Fade.tsx +32 -0
  125. package/src/wc/components/Fade/index.ts +1 -0
  126. package/src/wc/components/Field/Field.tsx +28 -0
  127. package/src/wc/components/Header/Header.tsx +33 -0
  128. package/src/wc/components/Icon/Icon.tsx +112 -0
  129. package/src/wc/components/Icon/flag-icon-catalog.ts +68 -0
  130. package/src/wc/components/Icon/index.ts +1 -0
  131. package/src/wc/components/Icon/system-icon-catalog.ts +798 -0
  132. package/src/wc/components/Input/Input.tsx +100 -0
  133. package/src/wc/components/Input/index.ts +1 -0
  134. package/src/wc/components/LabelWrap/LabelWrap.tsx +38 -0
  135. package/src/wc/components/Loader/Loader.tsx +47 -0
  136. package/src/wc/components/Loader/index.ts +1 -0
  137. package/src/wc/components/Menu/Menu.tsx +340 -0
  138. package/src/wc/components/Modal/Modal.tsx +197 -0
  139. package/src/wc/components/Pagination/Pagination.tsx +97 -0
  140. package/src/wc/components/PanelNav/PanelNav.tsx +666 -0
  141. package/src/wc/components/PanelNav/index.ts +7 -0
  142. package/src/wc/components/PanelNav/panel-nav-utils.ts +124 -0
  143. package/src/wc/components/RadioGroup/RadioGroup.tsx +128 -0
  144. package/src/wc/components/RadioGroup/index.ts +1 -0
  145. package/src/wc/components/Scrollbar/Scrollbar.tsx +189 -0
  146. package/src/wc/components/Select/Select.tsx +195 -0
  147. package/src/wc/components/Sidebar/Sidebar.tsx +245 -0
  148. package/src/wc/components/Skeleton/Skeleton.tsx +64 -0
  149. package/src/wc/components/Skeleton/index.ts +1 -0
  150. package/src/wc/components/Slider/Slider.tsx +68 -0
  151. package/src/wc/components/Slider/index.ts +1 -0
  152. package/src/wc/components/Surface/Surface.tsx +81 -0
  153. package/src/wc/components/TabGroup/TabGroup.tsx +188 -0
  154. package/src/wc/components/TabGroup/tab-item-utils.ts +24 -0
  155. package/src/wc/components/TabGroupNav/TabGroupNav.tsx +188 -0
  156. package/src/wc/components/Table/Table.tsx +268 -0
  157. package/src/wc/components/Tag/Tag.tsx +135 -0
  158. package/src/wc/components/Tag/index.ts +1 -0
  159. package/src/wc/components/Text/Text.tsx +100 -0
  160. package/src/wc/components/Text/index.ts +1 -0
  161. package/src/wc/components/Toast/Toast.tsx +174 -0
  162. package/src/wc/components/Toast/toast-service.ts +60 -0
  163. package/src/wc/components/Toggle/Toggle.tsx +43 -0
  164. package/src/wc/components/Toggle/index.ts +1 -0
  165. package/src/wc/components/ToggleButton/ToggleButton.tsx +120 -0
  166. package/src/wc/components/ToggleButtonGroup/ToggleButtonGroup.tsx +118 -0
  167. package/src/wc/components/Tooltip/Tooltip.tsx +196 -0
  168. package/src/wc/components.d.ts +3028 -0
  169. package/dist/components/p-DV8pmt1m.js +0 -2
  170. package/dist/components/p-DV8pmt1m.js.map +0 -1
  171. package/dist/components/p-STnE_bmQ.js +0 -2
  172. package/dist/components/p-STnE_bmQ.js.map +0 -1
  173. /package/dist/components/{p-D3CwGoIu.js.map → p-COsKOwZR.js.map} +0 -0
  174. /package/dist/components/{p-DouEVPmi.js.map → p-Dd0GrKaF.js.map} +0 -0
  175. /package/dist/components/{p-BvxKLqNx.js.map → p-H89PzqGU.js.map} +0 -0
  176. /package/dist/components/{p-BYe8Rfdg.js.map → p-Vwlmhl4E.js.map} +0 -0
  177. /package/dist/components/{p-C4p1fG67.js.map → p-woooYulR.js.map} +0 -0
@@ -0,0 +1,124 @@
1
+ import type { PanelNavGroup, PanelNavItem, PanelNavVariant } from './PanelNav';
2
+
3
+ /** Whether `path` matches `href` at a segment boundary (exact or child path). */
4
+ export function hrefMatchesPath(path: string, href: string): boolean {
5
+ return path === href || path.startsWith(`${href}/`);
6
+ }
7
+
8
+ /** Derive the active nav item id from a URL using longest segment-boundary prefix match. */
9
+ export function deriveActiveIdFromUrl(path: string, items: PanelNavItem[]): string {
10
+ if (!path) return '';
11
+
12
+ let bestId = '';
13
+ let bestLen = 0;
14
+
15
+ for (const item of items) {
16
+ if (!item.href) continue;
17
+ if (hrefMatchesPath(path, item.href) && item.href.length > bestLen) {
18
+ bestId = item.id;
19
+ bestLen = item.href.length;
20
+ }
21
+ }
22
+
23
+ return bestId;
24
+ }
25
+
26
+ /** Total nav item count across all groups. */
27
+ export function countPanelNavItems(groups: PanelNavGroup[]): number {
28
+ return groups.reduce((sum, g) => sum + g.items.length, 0);
29
+ }
30
+
31
+ /** Parse `groups` from either a JSON attribute string or a JS property array. */
32
+ export function parsePanelNavGroups(groups: string | unknown): PanelNavGroup[] {
33
+ if (Array.isArray(groups)) return groups;
34
+ if (typeof groups === 'string') {
35
+ try {
36
+ const parsed = JSON.parse(groups);
37
+ return Array.isArray(parsed) ? parsed : [];
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
42
+ return [];
43
+ }
44
+
45
+ /** `document.documentElement` attribute for pre-bootstrap variant hints (SPA hard reload). */
46
+ export const PANEL_NAV_VARIANT_HINT_ATTR = 'data-panel-nav-variant';
47
+
48
+ /** Parse `variant` from a host attribute value. */
49
+ export function readPanelNavVariantAttr(attr: string | null): PanelNavVariant | undefined {
50
+ if (attr === 'settings' || attr === 'dashboard') return attr;
51
+ return undefined;
52
+ }
53
+
54
+ /** Set a document-level variant hint before the custom element upgrades (hard reload). */
55
+ export function setPanelNavVariantHint(variant: PanelNavVariant): void {
56
+ if (typeof document === 'undefined') return;
57
+ document.documentElement.setAttribute(PANEL_NAV_VARIANT_HINT_ATTR, variant);
58
+ }
59
+
60
+ export function clearPanelNavVariantHint(): void {
61
+ if (typeof document === 'undefined') return;
62
+ document.documentElement.removeAttribute(PANEL_NAV_VARIANT_HINT_ATTR);
63
+ }
64
+
65
+ /** Resolve initial variant — host attr, then document hint, then prop default. */
66
+ export function resolvePanelNavVariant(
67
+ variantProp: PanelNavVariant,
68
+ variantAttr: string | null,
69
+ documentVariantAttr: string | null = typeof document !== 'undefined'
70
+ ? document.documentElement.getAttribute(PANEL_NAV_VARIANT_HINT_ATTR)
71
+ : null,
72
+ ): PanelNavVariant {
73
+ return (
74
+ readPanelNavVariantAttr(variantAttr) ??
75
+ readPanelNavVariantAttr(documentVariantAttr) ??
76
+ variantProp
77
+ );
78
+ }
79
+
80
+ /** Resolve whether VT is disabled from prop and/or host attribute. */
81
+ export function resolvePanelNavDisableVt(prop: boolean, attr: string | null): boolean {
82
+ if (prop) return true;
83
+ if (attr === null) return false;
84
+ return attr !== 'false';
85
+ }
86
+
87
+ /** True when internal rendered surface lags the host `variant` prop. */
88
+ export function shouldResyncPanelNavVariant(
89
+ renderedVariant: PanelNavVariant,
90
+ variant: PanelNavVariant,
91
+ ): boolean {
92
+ return renderedVariant !== variant;
93
+ }
94
+
95
+ /** True when host `groups` has items but internal parsed state is still empty. */
96
+ export function shouldResyncPanelNavGroups(
97
+ parsedGroups: PanelNavGroup[],
98
+ groups: string | unknown,
99
+ ): boolean {
100
+ return (
101
+ countPanelNavItems(parsedGroups) === 0 &&
102
+ countPanelNavItems(parsePanelNavGroups(groups)) > 0
103
+ );
104
+ }
105
+
106
+ let vtStyleInjected = false;
107
+
108
+ /** Inject view-transition suppress styles once per document (shared by PanelNav + hosts). */
109
+ export function ensurePanelNavVtStyle(): void {
110
+ if (vtStyleInjected) return;
111
+ const id = 'ds-panel-nav-vt-style';
112
+ if (document.getElementById(id)) {
113
+ vtStyleInjected = true;
114
+ return;
115
+ }
116
+ const style = document.createElement('style');
117
+ style.id = id;
118
+ style.textContent = [
119
+ '::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}',
120
+ '::view-transition-new(root){clip-path:circle(0px at var(--vt-x,50%) var(--vt-y,50%))}',
121
+ ].join('\n');
122
+ document.head.appendChild(style);
123
+ vtStyleInjected = true;
124
+ }
@@ -0,0 +1,128 @@
1
+ import { Component, Prop, Event, EventEmitter, Element, Listen, h, Host } from '@stencil/core';
2
+
3
+ export interface RadioOption {
4
+ label: string;
5
+ value: string;
6
+ inactive?: boolean;
7
+ }
8
+
9
+ @Component({
10
+ tag: 'ds-radio-group',
11
+ styleUrl: 'RadioGroup.css',
12
+ scoped: true,
13
+ })
14
+ export class RadioGroup {
15
+ @Element() el!: HTMLElement;
16
+
17
+ @Prop() options: RadioOption[] = [];
18
+ @Prop({ mutable: true }) value: string = '';
19
+ @Prop() direction: 'vertical' | 'horizontal' = 'vertical';
20
+ @Prop() inactive: boolean = false;
21
+ @Prop({ attribute: 'aria-label' }) ariaLabel: string | undefined;
22
+ @Prop({ attribute: 'aria-labelledby' }) ariaLabelledby: string | undefined;
23
+
24
+ @Event() dsChange!: EventEmitter<string>;
25
+
26
+ private get activeItems(): HTMLElement[] {
27
+ return Array.from(this.el.querySelectorAll<HTMLElement>('[data-radio-item]:not([data-inactive])'));
28
+ }
29
+
30
+ @Listen('keydown')
31
+ handleKeyDown(e: KeyboardEvent) {
32
+ const items = this.activeItems;
33
+ if (!items.length) return;
34
+
35
+ const focused = items.find(item => item === document.activeElement);
36
+ if (!focused) return;
37
+
38
+ const idx = items.indexOf(focused);
39
+ let next = -1;
40
+
41
+ switch (e.key) {
42
+ case 'ArrowDown':
43
+ case 'ArrowRight':
44
+ e.preventDefault();
45
+ next = (idx + 1) % items.length;
46
+ break;
47
+ case 'ArrowUp':
48
+ case 'ArrowLeft':
49
+ e.preventDefault();
50
+ next = (idx - 1 + items.length) % items.length;
51
+ break;
52
+ case 'Home':
53
+ e.preventDefault();
54
+ next = 0;
55
+ break;
56
+ case 'End':
57
+ e.preventDefault();
58
+ next = items.length - 1;
59
+ break;
60
+ }
61
+
62
+ if (next >= 0) {
63
+ items[next].focus();
64
+ const nextValue = items[next].dataset['value'];
65
+ if (nextValue && nextValue !== this.value) {
66
+ this.value = nextValue;
67
+ this.dsChange.emit(nextValue);
68
+ }
69
+ }
70
+ }
71
+
72
+ private selectItem(optValue: string) {
73
+ if (this.inactive) return;
74
+ if (optValue !== this.value) {
75
+ this.value = optValue;
76
+ this.dsChange.emit(optValue);
77
+ }
78
+ }
79
+
80
+ render() {
81
+ const selectedIdx = this.options.findIndex(o => o.value === this.value);
82
+ const firstActiveIdx = this.options.findIndex(o => !this.inactive && !o.inactive);
83
+ const focusableIdx = selectedIdx >= 0 && !this.inactive && !this.options[selectedIdx]?.inactive
84
+ ? selectedIdx
85
+ : firstActiveIdx;
86
+
87
+ return (
88
+ <Host
89
+ role="radiogroup"
90
+ aria-label={this.ariaLabel}
91
+ aria-labelledby={this.ariaLabelledby}
92
+ class={{ group: true, 'group--horizontal': this.direction === 'horizontal' }}
93
+ >
94
+ {this.options.map((opt, i) => {
95
+ const isItemInactive = this.inactive || !!opt.inactive;
96
+ const isChecked = opt.value === this.value;
97
+ const tabIdx = isItemInactive ? -1 : (i === focusableIdx ? 0 : -1);
98
+
99
+ return (
100
+ <div
101
+ key={opt.value}
102
+ role="radio"
103
+ aria-checked={String(isChecked)}
104
+ aria-disabled={isItemInactive || undefined}
105
+ tabIndex={tabIdx}
106
+ data-radio-item
107
+ data-value={opt.value}
108
+ data-inactive={isItemInactive || undefined}
109
+ class={{ 'radio-item': true, 'radio-item--inactive': isItemInactive }}
110
+ onClick={() => !isItemInactive && this.selectItem(opt.value)}
111
+ onKeyDown={(e: KeyboardEvent) => {
112
+ if ((e.key === ' ' || e.key === 'Enter') && !isItemInactive) {
113
+ e.preventDefault();
114
+ this.selectItem(opt.value);
115
+ }
116
+ }}
117
+ >
118
+ <span class={{ circle: true, 'circle--checked': isChecked }}>
119
+ {isChecked && <span class="dot" />}
120
+ </span>
121
+ <span class="text-body-medium radio-label">{opt.label}</span>
122
+ </div>
123
+ );
124
+ })}
125
+ </Host>
126
+ );
127
+ }
128
+ }
@@ -0,0 +1 @@
1
+ export type { RadioOption } from './RadioGroup';
@@ -0,0 +1,189 @@
1
+ import { Component, Prop, State, Element, h, Host } from '@stencil/core';
2
+
3
+ export type ScrollbarVariant = 'default' | 'thick';
4
+
5
+ @Component({
6
+ tag: 'ds-scrollbar',
7
+ styleUrl: 'Scrollbar.css',
8
+ scoped: true,
9
+ })
10
+ export class Scrollbar {
11
+ @Element() el!: HTMLElement;
12
+
13
+ @Prop() variant: ScrollbarVariant = 'default';
14
+ @Prop() showTrackOnHover: boolean = true;
15
+
16
+ @State() private thumbHeight: number = 0;
17
+ @State() private thumbTop: number = 0;
18
+ @State() private thumbWidth: number = 0;
19
+ @State() private thumbLeft: number = 0;
20
+ @State() private isDragging: boolean = false;
21
+ @State() private isDraggingH: boolean = false;
22
+ @State() private isPointerInside: boolean = false;
23
+
24
+ private contentEl: HTMLElement | null = null;
25
+ private resizeObserver: ResizeObserver | null = null;
26
+ private scrollHandler: (() => void) | null = null;
27
+
28
+ // Drag state (not reactive — no re-render needed)
29
+ private dragStartY = 0;
30
+ private dragStartScrollTop = 0;
31
+ private dragStartX = 0;
32
+ private dragStartScrollLeft = 0;
33
+
34
+ componentDidLoad() {
35
+ this.contentEl = this.el.querySelector('.scrollbar-content') as HTMLElement | null;
36
+ if (!this.contentEl) return;
37
+
38
+ this.scrollHandler = () => this.updateThumb();
39
+ this.contentEl.addEventListener('scroll', this.scrollHandler);
40
+
41
+ this.resizeObserver = new ResizeObserver(() => this.updateThumb());
42
+ this.resizeObserver.observe(this.contentEl);
43
+ window.addEventListener('resize', this.scrollHandler);
44
+
45
+ this.updateThumb();
46
+ }
47
+
48
+ disconnectedCallback() {
49
+ if (this.contentEl && this.scrollHandler) {
50
+ this.contentEl.removeEventListener('scroll', this.scrollHandler);
51
+ }
52
+ if (this.scrollHandler) {
53
+ window.removeEventListener('resize', this.scrollHandler);
54
+ }
55
+ this.resizeObserver?.disconnect();
56
+ }
57
+
58
+ private updateThumb() {
59
+ const container = this.el.querySelector('.scrollbar-container') as HTMLElement | null;
60
+ const content = this.contentEl;
61
+ if (!container || !content) return;
62
+
63
+ const cH = container.clientHeight;
64
+ const cW = container.clientWidth;
65
+ const sH = content.scrollHeight;
66
+ const sW = content.scrollWidth;
67
+ const st = content.scrollTop;
68
+ const sl = content.scrollLeft;
69
+
70
+ if (sH <= cH) {
71
+ this.thumbHeight = 0;
72
+ } else {
73
+ const h = Math.max(20, cH * (cH / sH));
74
+ this.thumbHeight = h;
75
+ this.thumbTop = (st / (sH - cH)) * (cH - h);
76
+ }
77
+
78
+ if (sW <= cW) {
79
+ this.thumbWidth = 0;
80
+ } else {
81
+ const w = Math.max(20, cW * (cW / sW));
82
+ this.thumbWidth = w;
83
+ this.thumbLeft = (sl / (sW - cW)) * (cW - w);
84
+ }
85
+ }
86
+
87
+ private handleThumbMouseDown(e: MouseEvent) {
88
+ e.preventDefault();
89
+ e.stopPropagation();
90
+ const content = this.contentEl;
91
+ if (!content) return;
92
+
93
+ this.isDragging = true;
94
+ this.dragStartY = e.clientY;
95
+ this.dragStartScrollTop = content.scrollTop;
96
+
97
+ const container = this.el.querySelector('.scrollbar-container') as HTMLElement;
98
+
99
+ const onMove = (ev: MouseEvent) => {
100
+ const deltaY = ev.clientY - this.dragStartY;
101
+ const scrollable = content.scrollHeight - container.clientHeight;
102
+ const ratio = scrollable / (container.clientHeight - this.thumbHeight);
103
+ content.scrollTop = Math.max(0, Math.min(scrollable, this.dragStartScrollTop + deltaY * ratio));
104
+ };
105
+ const onUp = () => {
106
+ this.isDragging = false;
107
+ document.removeEventListener('mousemove', onMove);
108
+ document.removeEventListener('mouseup', onUp);
109
+ };
110
+ document.addEventListener('mousemove', onMove);
111
+ document.addEventListener('mouseup', onUp);
112
+ }
113
+
114
+ private handleThumbHMouseDown(e: MouseEvent) {
115
+ e.preventDefault();
116
+ e.stopPropagation();
117
+ const content = this.contentEl;
118
+ if (!content) return;
119
+
120
+ this.isDraggingH = true;
121
+ this.dragStartX = e.clientX;
122
+ this.dragStartScrollLeft = content.scrollLeft;
123
+
124
+ const container = this.el.querySelector('.scrollbar-container') as HTMLElement;
125
+
126
+ const onMove = (ev: MouseEvent) => {
127
+ const deltaX = ev.clientX - this.dragStartX;
128
+ const scrollable = content.scrollWidth - container.clientWidth;
129
+ const ratio = scrollable / (container.clientWidth - this.thumbWidth);
130
+ content.scrollLeft = Math.max(0, Math.min(scrollable, this.dragStartScrollLeft + deltaX * ratio));
131
+ };
132
+ const onUp = () => {
133
+ this.isDraggingH = false;
134
+ document.removeEventListener('mousemove', onMove);
135
+ document.removeEventListener('mouseup', onUp);
136
+ };
137
+ document.addEventListener('mousemove', onMove);
138
+ document.addEventListener('mouseup', onUp);
139
+ }
140
+
141
+ render() {
142
+ const showThumb = this.thumbHeight > 0;
143
+ const showThumbH = this.thumbWidth > 0;
144
+ const isScrollable = showThumb || showThumbH;
145
+ const showTrack = this.showTrackOnHover && (this.isPointerInside || this.isDragging || this.isDraggingH);
146
+
147
+ return (
148
+ <Host style={{ display: 'contents' }}>
149
+ <div
150
+ class={{
151
+ 'scrollbar-container': true,
152
+ 'scrollbar-container--thick': this.variant === 'thick',
153
+ }}
154
+ data-show-track={showTrack ? 'true' : 'false'}
155
+ data-dragging={this.isDragging || this.isDraggingH}
156
+ onMouseEnter={() => { if (this.showTrackOnHover) this.isPointerInside = true; }}
157
+ onMouseLeave={() => { this.isPointerInside = false; }}
158
+ >
159
+ <div
160
+ class="scrollbar-content"
161
+ tabIndex={isScrollable ? 0 : undefined}
162
+ >
163
+ <slot />
164
+ </div>
165
+
166
+ {showThumb && (
167
+ <div class="scrollbar-track" aria-hidden="true">
168
+ <div
169
+ class="scrollbar-thumb"
170
+ style={{ height: `${this.thumbHeight}px`, top: `${this.thumbTop}px` }}
171
+ onMouseDown={(e: MouseEvent) => this.handleThumbMouseDown(e)}
172
+ />
173
+ </div>
174
+ )}
175
+
176
+ {showThumbH && (
177
+ <div class="scrollbar-track-h" aria-hidden="true">
178
+ <div
179
+ class="scrollbar-thumb-h"
180
+ style={{ width: `${this.thumbWidth}px`, left: `${this.thumbLeft}px` }}
181
+ onMouseDown={(e: MouseEvent) => this.handleThumbHMouseDown(e)}
182
+ />
183
+ </div>
184
+ )}
185
+ </div>
186
+ </Host>
187
+ );
188
+ }
189
+ }
@@ -0,0 +1,195 @@
1
+ import { Component, Prop, State, Event, EventEmitter, Element, Watch, Listen, h, Host } from '@stencil/core';
2
+ import type { MenuItemData } from '../Menu/Menu';
3
+
4
+ export interface SelectOption {
5
+ label: string;
6
+ value: string;
7
+ }
8
+
9
+ @Component({
10
+ tag: 'ds-select',
11
+ styleUrl: 'Select.css',
12
+ scoped: true,
13
+ })
14
+ export class Select {
15
+ @Element() el!: HTMLElement;
16
+
17
+ /**
18
+ * Array of options. Set via JS property.
19
+ * @example el.options = [{ label: 'Apple', value: 'apple' }];
20
+ */
21
+ @Prop() options: SelectOption[] = [];
22
+
23
+ /** Currently selected value. */
24
+ @Prop({ mutable: true }) value: string = '';
25
+
26
+ /** Placeholder shown when no value is selected. */
27
+ @Prop() placeholder: string = 'Select option';
28
+
29
+ /** Disables interaction. */
30
+ @Prop() inactive: boolean = false;
31
+
32
+ @Prop({ attribute: 'aria-label' }) ariaLabel: string | undefined;
33
+ @Prop({ attribute: 'aria-labelledby' }) ariaLabelledby: string | undefined;
34
+
35
+ /** Emits the selected value string. */
36
+ @Event() dsChange!: EventEmitter<string>;
37
+
38
+ @State() private isOpen: boolean = false;
39
+
40
+ private triggerEl: HTMLButtonElement | null = null;
41
+ private menuEl: HTMLDsMenuElement | null = null;
42
+
43
+ componentDidLoad() {
44
+ // Wire up the menu anchor to the trigger button
45
+ if (this.menuEl && this.triggerEl) {
46
+ this.menuEl.anchor = this.triggerEl;
47
+ }
48
+ // Listen for menu selection events
49
+ this.menuEl?.addEventListener('dsSelect', (e: Event) => {
50
+ const detail = (e as CustomEvent<MenuItemData>).detail;
51
+ if (detail?.value !== undefined) {
52
+ this.value = String(detail.value);
53
+ this.dsChange.emit(this.value);
54
+ }
55
+ this.isOpen = false;
56
+ });
57
+ // Listen for menu close event
58
+ this.menuEl?.addEventListener('dsClose', () => {
59
+ this.isOpen = false;
60
+ });
61
+ }
62
+
63
+ @Watch('isOpen')
64
+ onIsOpenChange(open: boolean) {
65
+ if (this.menuEl) {
66
+ this.menuEl.open = open;
67
+ }
68
+ }
69
+
70
+ @Watch('options')
71
+ onOptionsChange() {
72
+ this.syncMenuItems();
73
+ }
74
+
75
+ @Watch('value')
76
+ onValueChange() {
77
+ this.syncMenuItems();
78
+ }
79
+
80
+ private syncMenuItems() {
81
+ if (!this.menuEl) return;
82
+ this.menuEl.items = this.options.map(opt => ({
83
+ label: opt.label,
84
+ value: opt.value,
85
+ isSelected: opt.value === this.value,
86
+ }));
87
+ }
88
+
89
+ private open() {
90
+ if (this.inactive || !this.options.length) return;
91
+ // Sync menu width to trigger width
92
+ if (this.menuEl && this.triggerEl) {
93
+ this.menuEl.menuWidth = `${this.triggerEl.offsetWidth}px`;
94
+ this.menuEl.anchor = this.triggerEl;
95
+ }
96
+ this.syncMenuItems();
97
+ this.isOpen = true;
98
+ }
99
+
100
+ private close() {
101
+ this.isOpen = false;
102
+ }
103
+
104
+ private toggle() {
105
+ if (this.isOpen) {
106
+ this.close();
107
+ } else {
108
+ this.open();
109
+ }
110
+ }
111
+
112
+ @Listen('keydown')
113
+ handleKeyDown(e: KeyboardEvent) {
114
+ // Only handle keydown on the trigger button
115
+ if (document.activeElement !== this.triggerEl) return;
116
+
117
+ if (!this.isOpen) {
118
+ switch (e.key) {
119
+ case 'ArrowDown':
120
+ case 'ArrowUp':
121
+ case 'Enter':
122
+ case ' ':
123
+ e.preventDefault();
124
+ this.open();
125
+ break;
126
+ }
127
+ } else {
128
+ if (e.key === 'Escape') {
129
+ e.preventDefault();
130
+ this.close();
131
+ this.triggerEl?.focus();
132
+ }
133
+ }
134
+ }
135
+
136
+ private get selectedLabel(): string {
137
+ const found = this.options.find(o => o.value === this.value);
138
+ return found?.label ?? '';
139
+ }
140
+
141
+ private get hasSelection(): boolean {
142
+ return !!this.options.find(o => o.value === this.value);
143
+ }
144
+
145
+ render() {
146
+ const label = this.hasSelection ? this.selectedLabel : this.placeholder;
147
+ const showPlaceholder = !this.hasSelection;
148
+
149
+ return (
150
+ <Host class={{ 'select-host': true, inactive: this.inactive }}>
151
+ <button
152
+ ref={el => {
153
+ this.triggerEl = (el as HTMLButtonElement) ?? null;
154
+ }}
155
+ type="button"
156
+ class={{ trigger: true, 'trigger--open': this.isOpen }}
157
+ disabled={this.inactive}
158
+ role="combobox"
159
+ aria-haspopup="listbox"
160
+ aria-expanded={this.isOpen}
161
+ aria-label={this.ariaLabel}
162
+ aria-labelledby={this.ariaLabelledby}
163
+ onClick={() => this.toggle()}
164
+ >
165
+ <span class={{ 'trigger__label': true, 'trigger__label--placeholder': showPlaceholder }}>
166
+ {label}
167
+ </span>
168
+ <svg
169
+ class="trigger__chevron"
170
+ width="16"
171
+ height="16"
172
+ viewBox="0 0 16 16"
173
+ fill="none"
174
+ aria-hidden="true"
175
+ >
176
+ <path
177
+ d="M4 6L8 10L12 6"
178
+ stroke="currentColor"
179
+ stroke-width="1.25"
180
+ stroke-linecap="round"
181
+ stroke-linejoin="round"
182
+ />
183
+ </svg>
184
+ </button>
185
+ <ds-menu
186
+ ref={el => {
187
+ this.menuEl = (el as HTMLDsMenuElement) ?? null;
188
+ }}
189
+ side="bottom"
190
+ align="start"
191
+ />
192
+ </Host>
193
+ );
194
+ }
195
+ }