@vanilla-bean/components 1.0.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 (147) hide show
  1. package/Component/Component.js +598 -0
  2. package/Component/Component.scenarios.js +88 -0
  3. package/Component/Component.test.js +717 -0
  4. package/Component/README.md +455 -0
  5. package/Component/index.js +3 -0
  6. package/Component/observeElementConnection.js +52 -0
  7. package/Component/observeElementConnection.test.js +121 -0
  8. package/Elem/Elem.js +304 -0
  9. package/Elem/Elem.test.js +679 -0
  10. package/Elem/README.md +373 -0
  11. package/Elem/index.js +1 -0
  12. package/FontWithASyntaxHighlighter-Regular.woff2 +0 -0
  13. package/LICENSE +21 -0
  14. package/README.md +413 -0
  15. package/components/BottomSheet/BottomSheet.js +192 -0
  16. package/components/BottomSheet/BottomSheet.lld.md +25 -0
  17. package/components/BottomSheet/README.md +66 -0
  18. package/components/BottomSheet/index.js +1 -0
  19. package/components/Button/Button.js +53 -0
  20. package/components/Button/Button.lld.md +21 -0
  21. package/components/Button/index.js +1 -0
  22. package/components/Calendar/Calendar.js +720 -0
  23. package/components/Calendar/Calendar.lld.md +22 -0
  24. package/components/Calendar/CalendarEvent.js +102 -0
  25. package/components/Calendar/Toolbar.js +78 -0
  26. package/components/Calendar/index.js +2 -0
  27. package/components/Calendar/utils.js +56 -0
  28. package/components/Code/Code.js +84 -0
  29. package/components/Code/Code.lld.md +21 -0
  30. package/components/Code/index.js +1 -0
  31. package/components/ColorPicker/ColorPicker.js +445 -0
  32. package/components/ColorPicker/ColorPicker.lld.md +21 -0
  33. package/components/ColorPicker/index.js +1 -0
  34. package/components/ColorPicker/svg.js +5 -0
  35. package/components/Dialog/Dialog.js +278 -0
  36. package/components/Dialog/Dialog.lld.md +20 -0
  37. package/components/Dialog/README.md +96 -0
  38. package/components/Dialog/index.js +1 -0
  39. package/components/Form/Form.js +257 -0
  40. package/components/Form/Form.lld.md +21 -0
  41. package/components/Form/README.md +87 -0
  42. package/components/Form/index.js +1 -0
  43. package/components/Icon/Icon.js +54 -0
  44. package/components/Icon/Icon.lld.md +21 -0
  45. package/components/Icon/index.js +1 -0
  46. package/components/Input/Input.js +173 -0
  47. package/components/Input/Input.lld.md +28 -0
  48. package/components/Input/README.md +97 -0
  49. package/components/Input/index.js +2 -0
  50. package/components/Input/utils.js +122 -0
  51. package/components/Keyboard/Key.js +38 -0
  52. package/components/Keyboard/Keyboard.js +173 -0
  53. package/components/Keyboard/Keyboard.lld.md +21 -0
  54. package/components/Keyboard/index.js +1 -0
  55. package/components/Label/Label.js +214 -0
  56. package/components/Label/Label.lld.md +20 -0
  57. package/components/Label/index.js +1 -0
  58. package/components/Link/Link.js +43 -0
  59. package/components/Link/Link.lld.md +15 -0
  60. package/components/Link/index.js +1 -0
  61. package/components/List/List.js +82 -0
  62. package/components/List/List.lld.md +19 -0
  63. package/components/List/index.js +1 -0
  64. package/components/Menu/Menu.js +93 -0
  65. package/components/Menu/Menu.lld.md +15 -0
  66. package/components/Menu/index.js +1 -0
  67. package/components/Notify/Notify.js +96 -0
  68. package/components/Notify/Notify.lld.md +20 -0
  69. package/components/Notify/index.js +1 -0
  70. package/components/Page/Page.js +67 -0
  71. package/components/Page/Page.lld.md +20 -0
  72. package/components/Page/index.js +1 -0
  73. package/components/Popover/Popover.js +175 -0
  74. package/components/Popover/Popover.lld.md +19 -0
  75. package/components/Popover/index.js +1 -0
  76. package/components/RadioButton/RadioButton.js +108 -0
  77. package/components/RadioButton/RadioButton.lld.md +15 -0
  78. package/components/RadioButton/index.js +1 -0
  79. package/components/Router/README.md +160 -0
  80. package/components/Router/Router.js +150 -0
  81. package/components/Router/Router.lld.md +31 -0
  82. package/components/Router/View.js +15 -0
  83. package/components/Router/index.js +2 -0
  84. package/components/Router/utils.js +17 -0
  85. package/components/Select/README.md +88 -0
  86. package/components/Select/Select.js +74 -0
  87. package/components/Select/Select.lld.md +20 -0
  88. package/components/Select/index.js +1 -0
  89. package/components/Table/README.md +94 -0
  90. package/components/Table/Table.js +171 -0
  91. package/components/Table/Table.lld.md +21 -0
  92. package/components/Table/index.js +1 -0
  93. package/components/TagList/Tag.js +84 -0
  94. package/components/TagList/TagList.js +118 -0
  95. package/components/TagList/TagList.lld.md +30 -0
  96. package/components/TagList/design.excalidraw.png +0 -0
  97. package/components/TagList/index.js +2 -0
  98. package/components/Tooltip/Tooltip.js +139 -0
  99. package/components/Tooltip/Tooltip.lld.md +22 -0
  100. package/components/Tooltip/index.js +1 -0
  101. package/components/TooltipWrapper/TooltipWrapper.js +89 -0
  102. package/components/TooltipWrapper/TooltipWrapper.lld.md +21 -0
  103. package/components/TooltipWrapper/index.js +1 -0
  104. package/components/Whiteboard/Whiteboard.js +198 -0
  105. package/components/Whiteboard/Whiteboard.lld.md +35 -0
  106. package/components/Whiteboard/index.js +1 -0
  107. package/components/index.js +27 -0
  108. package/eslint.config.cjs +118 -0
  109. package/index.d.ts +635 -0
  110. package/index.js +19 -0
  111. package/package.json +123 -0
  112. package/plugins/asText.js +38 -0
  113. package/plugins/loadPlugins.js +5 -0
  114. package/plugins/markdownLoader.js +121 -0
  115. package/prettier.config.cjs +7 -0
  116. package/spellcheck.config.cjs +227 -0
  117. package/styled/README.md +329 -0
  118. package/styled/appendStyles.js +26 -0
  119. package/styled/appendStyles.test.js +45 -0
  120. package/styled/index.js +4 -0
  121. package/styled/shimCSS.js +31 -0
  122. package/styled/shimCSS.test.js +103 -0
  123. package/styled/styled.js +91 -0
  124. package/styled/styled.test.js +586 -0
  125. package/styled/themeStyles.js +36 -0
  126. package/styled/themeStyles.test.js +135 -0
  127. package/test-setup.js +123 -0
  128. package/theme/.test.js +69 -0
  129. package/theme/README.md +607 -0
  130. package/theme/button.js +100 -0
  131. package/theme/code.js +123 -0
  132. package/theme/colors.js +42 -0
  133. package/theme/fonts.js +42 -0
  134. package/theme/index.js +33 -0
  135. package/theme/input.js +64 -0
  136. package/theme/page.js +208 -0
  137. package/theme/scrollbar.js +24 -0
  138. package/theme/table.js +53 -0
  139. package/utils/README.md +176 -0
  140. package/utils/browser.js +92 -0
  141. package/utils/class.js +30 -0
  142. package/utils/color.js +81 -0
  143. package/utils/data.js +164 -0
  144. package/utils/element.js +55 -0
  145. package/utils/index.js +7 -0
  146. package/utils/rand.js +12 -0
  147. package/utils/string.js +72 -0
@@ -0,0 +1,171 @@
1
+ import { capitalize, orderBy } from '../../utils';
2
+ import theme from '../../theme';
3
+ import { Elem } from '../../Elem';
4
+ import { Component } from '../../Component';
5
+ import { Icon } from '../Icon';
6
+
7
+ const defaultOptions = { tag: 'table' };
8
+
9
+ /**
10
+ * Data table component with sorting, column configuration, and footer support.
11
+ *
12
+ * Renders tabular data with configurable columns, sortable headers, and optional footer.
13
+ * Supports custom cell rendering and automatic sorting functionality.
14
+ * @param {object} [options={}] - Table configuration options
15
+ * @param {string} [options.tag='table'] - HTML tag for the table element
16
+ * @param {Array<string|object>} options.columns - Column definitions with keys and optional configurations
17
+ * @param {Array<object>} [options.data] - Array of data objects to display in table rows
18
+ * @param {Array<string|object>} [options.footer] - Footer row data
19
+ * @param {Function} [options.onSort] - Custom sort function, defaults to built-in sorting
20
+ * @param {string} [options.sortProperty] - Currently sorted column key
21
+ * @param {string} [options.sortDirection] - Sort direction ('asc' or 'desc')
22
+ * @param {...(Component|HTMLElement|string)} children - Child elements to append
23
+ * @returns {Table} Table component instance
24
+ */
25
+ class Table extends Component {
26
+ constructor(options = {}, ...children) {
27
+ super(
28
+ {
29
+ ...defaultOptions,
30
+ onSort: (property, direction) => {
31
+ this.options.data = this.options.data.sort(orderBy({ property, direction }));
32
+ },
33
+ ...options,
34
+ columns: (options.columns || []).map(column =>
35
+ typeof column === 'string' ? { key: column, content: capitalize(column) } : column,
36
+ ),
37
+ footer: options.footer?.map(column => (typeof column === 'string' ? { content: capitalize(column) } : column)),
38
+ },
39
+ ...children,
40
+ );
41
+ }
42
+
43
+ static handlers = {
44
+ sortDirection(value) {
45
+ if (this.rendered) this.options.onSort(this.options.sortProperty, value);
46
+ },
47
+ data() {
48
+ this._renderTable();
49
+ },
50
+ selection() {
51
+ this._renderTable();
52
+ },
53
+ };
54
+
55
+ _renderTable() {
56
+ const value = this.options.data;
57
+
58
+ this._sortSubscribers?.forEach(sub => sub.destroy?.());
59
+ this._sortSubscribers = null;
60
+
61
+ if (!value) return;
62
+
63
+ if (this.thead) this.thead.empty();
64
+ else this.thead = new Elem({ tag: 'thead', appendTo: this });
65
+
66
+ if (this.tbody) this.tbody.empty();
67
+ else this.tbody = new Elem({ tag: 'tbody', appendTo: this });
68
+
69
+ if (this.tfoot) this.tfoot.empty();
70
+ else this.tfoot = new Elem({ tag: 'tfoot', appendTo: this });
71
+
72
+ this._sortSubscribers = [];
73
+ this.replaceCleanup('sortSubscribers', () => this._sortSubscribers?.forEach(sub => sub.destroy?.()));
74
+
75
+ this.thead.append(
76
+ new Component(
77
+ { tag: 'tr' },
78
+ this.options.columns.map(column => {
79
+ const th = new Component({ tag: 'th', ...column, scope: 'col' });
80
+
81
+ if (column.sort) {
82
+ const iconSub = this.options.subscriber('sortDirection', () => {
83
+ if (this.options.sortProperty !== column.key) return 'sort';
84
+
85
+ return this.options.sortDirection === 'asc' ? 'sort-down' : 'sort-up';
86
+ });
87
+ const styleSub = this.options.subscriber('sortDirection', () => ({
88
+ display: 'inline',
89
+ marginLeft: '6px',
90
+ ...(this.options.sortProperty !== column.key
91
+ ? { color: theme.colors.dark(theme.colors.gray) }
92
+ : { color: theme.colors.white }),
93
+ }));
94
+
95
+ this._sortSubscribers.push(iconSub, styleSub);
96
+
97
+ const updateAriaSort = () => {
98
+ let ariaSort = 'none';
99
+ if (this.options.sortProperty === column.key) {
100
+ ariaSort = this.options.sortDirection === 'asc' ? 'ascending' : 'descending';
101
+ }
102
+ th.elem.setAttribute('aria-sort', ariaSort);
103
+ };
104
+ updateAriaSort();
105
+
106
+ const sortDirHandler = () => updateAriaSort();
107
+ const sortPropHandler = () => updateAriaSort();
108
+ this.options.addEventListener('sortDirection', sortDirHandler);
109
+ this.options.addEventListener('sortProperty', sortPropHandler);
110
+ this._sortSubscribers.push({
111
+ destroy: () => {
112
+ this.options.removeEventListener('sortDirection', sortDirHandler);
113
+ this.options.removeEventListener('sortProperty', sortPropHandler);
114
+ },
115
+ });
116
+
117
+ new Icon({
118
+ icon: iconSub,
119
+ style: styleSub,
120
+ appendTo: th,
121
+ });
122
+
123
+ th.onPointerPress(() => {
124
+ if (this.options.sortProperty === column.key) {
125
+ this.options.sortDirection = this.options.sortDirection === 'asc' ? 'desc' : 'asc';
126
+ return;
127
+ }
128
+
129
+ this.options.sortProperty = column.key;
130
+ this.options.sortDirection = 'desc';
131
+ });
132
+ }
133
+
134
+ return th;
135
+ }),
136
+ ),
137
+ );
138
+
139
+ this.tbody.append(
140
+ this.options.data.map(
141
+ rowData =>
142
+ new Component(
143
+ { tag: 'tr' },
144
+ this.options.columns.map(
145
+ column =>
146
+ new Component(
147
+ {
148
+ tag: 'td',
149
+ ...(typeof column.dataColumn === 'function'
150
+ ? column.dataColumn({ column, rowData, table: this })
151
+ : column.dataColumn),
152
+ },
153
+ column.key ? rowData[column.key]?.toString() : undefined,
154
+ ),
155
+ ),
156
+ ),
157
+ ),
158
+ );
159
+
160
+ if (this.options.footer) {
161
+ this.tfoot.append(
162
+ new Component(
163
+ { tag: 'tr' },
164
+ this.options.footer.map(footData => new Component({ tag: 'td', ...footData })),
165
+ ),
166
+ );
167
+ }
168
+ }
169
+ }
170
+
171
+ export default Table;
@@ -0,0 +1,21 @@
1
+ # Table
2
+
3
+ > ./Table.js
4
+
5
+ Sortable data table where sort state is explicit options. The design decision: `sortProperty` and `sortDirection` are readable and writable options; the sort state is in the component's options object, not hidden inside event handlers, so it's readable and settable from outside.
6
+
7
+ ## Sort state lives in options — externally readable and settable
8
+
9
+ - clicking a column header updates `sortProperty` and `sortDirection` as regular options; external code can read or set sort state without querying the DOM
10
+ - does clicking a sortable column update sortProperty to that column's key?
11
+ - does clicking an already-sorted column toggle sortDirection?
12
+
13
+ ## Custom cell renderers receive the full row context, not just the cell value
14
+
15
+ - a column's `dataColumn` function receives `{ column, rowData, table }` so cells can cross-reference other columns or interact with the table
16
+ - does a dataColumn function receive the complete rowData object for its row?
17
+
18
+ ## Footer aligns with data columns, not with DOM order
19
+
20
+ - footer cells are positioned by column key; adding or reordering columns does not misalign footer labels from data
21
+ - does the footer appear in the correct columns regardless of column order?
@@ -0,0 +1 @@
1
+ export { default as Table } from './Table';
@@ -0,0 +1,84 @@
1
+ import { Component } from '../../Component';
2
+ import { styled } from '../../styled';
3
+ import { Button } from '../Button';
4
+ import { Elem } from '../../Elem';
5
+
6
+ const StyledComponent = styled(
7
+ Component,
8
+ ({ colors }) => `
9
+ padding: 3px 6px;
10
+ margin: 0;
11
+ display: inline-flex;
12
+ align-items: center;
13
+ gap: 4px;
14
+ font-size: 0.85em;
15
+ line-height: 1;
16
+ background-color: ${colors.black};
17
+ border: 1px solid ${colors.alpha(colors.teal, 0.35)};
18
+ border-radius: 3px;
19
+ white-space: nowrap;
20
+ user-select: none;
21
+ cursor: default;
22
+ list-style: none;
23
+
24
+ &:not(.readOnly):not(.add-tag):hover {
25
+ border-color: ${colors.light(colors.teal)};
26
+ }
27
+
28
+ &.add-tag {
29
+ padding: 0;
30
+ border: none;
31
+ background: none;
32
+ gap: 4px;
33
+ cursor: text;
34
+ }
35
+
36
+ &:not(.add-tag) button {
37
+ width: 14px !important;
38
+ height: 14px !important;
39
+ font-size: 8px;
40
+ opacity: 0.4;
41
+ flex-shrink: 0;
42
+ background: transparent !important;
43
+
44
+ &:after { display: none; }
45
+
46
+ &:hover, &:focus {
47
+ opacity: 0.9;
48
+ background: transparent !important;
49
+ top: -1px;
50
+ }
51
+ }
52
+ `,
53
+ );
54
+
55
+ const defaultOptions = { tag: 'li', readOnly: false };
56
+
57
+ class Tag extends StyledComponent {
58
+ defaultOptions = { ...super.defaultOptions, ...defaultOptions };
59
+
60
+ constructor(options = {}, ...children) {
61
+ super({ tabindex: '0', ...defaultOptions, ...options }, ...children);
62
+ }
63
+
64
+ build() {
65
+ this._textSpan = new Elem({ tag: 'span', appendTo: this });
66
+
67
+ if (!this.options.readOnly) {
68
+ new Button({
69
+ icon: 'close',
70
+ appendTo: this,
71
+ onPointerPress: () => this.destroy(),
72
+ });
73
+ }
74
+ }
75
+
76
+ static handlers = {
77
+ textContent(value) {
78
+ this.elem.setAttribute('data-value', value);
79
+ this._textSpan.elem.textContent = value;
80
+ },
81
+ };
82
+ }
83
+
84
+ export default Tag;
@@ -0,0 +1,118 @@
1
+ import { Component } from '../../Component';
2
+ import { styled } from '../../styled';
3
+ import { Button } from '../Button';
4
+ import { Input } from '../Input';
5
+
6
+ import Tag from './Tag';
7
+
8
+ const StyledComponent = styled(
9
+ Component,
10
+ () => `
11
+ display: flex;
12
+ flex-wrap: wrap;
13
+ align-items: center;
14
+ gap: 4px;
15
+ list-style: none;
16
+ margin: 0;
17
+ padding: 4px;
18
+ box-sizing: border-box;
19
+
20
+ &.readOnly {
21
+ pointer-events: none;
22
+ padding: 0;
23
+ }
24
+ `,
25
+ );
26
+
27
+ const TagListInput = styled(
28
+ Input,
29
+ () => `
30
+ flex: 1;
31
+ min-width: 80px;
32
+ `,
33
+ );
34
+
35
+ const defaultOptions = { readOnly: false };
36
+
37
+ /**
38
+ * Interactive tag list component with add/remove functionality and input support.
39
+ *
40
+ * Displays a collection of tags with optional editing capabilities including
41
+ * inline text input for adding new tags and removal buttons for existing tags.
42
+ * @param {object} [options={}] - TagList configuration options
43
+ * @param {string} [options.tag='ul'] - HTML tag for the container element
44
+ * @param {boolean} [options.readOnly=false] - Whether tags can be added/removed
45
+ * @param {Array<string>} [options.tags] - Initial array of tag text values
46
+ * @param {...(Component|HTMLElement|string)} children - Child elements to append
47
+ * @returns {TagList} TagList component instance
48
+ */
49
+ class TagList extends StyledComponent {
50
+ defaultOptions = { ...super.defaultOptions, ...defaultOptions };
51
+
52
+ constructor(options = {}, ...children) {
53
+ super({ ...options, tag: 'ul' }, ...children);
54
+ }
55
+
56
+ _addTag(value) {
57
+ value = (value || '').trim();
58
+ if (!value) return;
59
+
60
+ const existing = Array.from(this.elem.querySelectorAll('li[data-value]')).map(el => el.dataset.value);
61
+ if (existing.includes(value)) return;
62
+
63
+ this.elem.insertBefore(new Tag({ textContent: value }).elem, this.addTag.elem);
64
+ this.tagInput.elem.value = '';
65
+ this.tagInput.elem.focus();
66
+ }
67
+
68
+ build() {
69
+ if (!this.options.readOnly) {
70
+ this.tagInput = new TagListInput({ placeholder: 'New Tag' });
71
+
72
+ this.tagInput.on({
73
+ targetEvent: 'keyup',
74
+ callback: e => {
75
+ if (e.key === 'Enter') this._addTag(this.tagInput.elem.value);
76
+ },
77
+ });
78
+
79
+ this.addTag = new Tag(
80
+ { readOnly: true, addClass: 'add-tag' },
81
+ this.tagInput,
82
+ new Button({
83
+ icon: 'plus',
84
+ onPointerPress: () => this._addTag(this.tagInput.elem.value),
85
+ }),
86
+ );
87
+
88
+ this.replaceCleanup('tagListChildren', () => {
89
+ this.addTag?.destroy?.();
90
+ });
91
+ }
92
+
93
+ this.append([
94
+ ...(this.options.tags || []).map(textContent => new Tag({ readOnly: this.options.readOnly, textContent })),
95
+ ...(this.addTag ? [this.addTag] : []),
96
+ ]);
97
+ }
98
+ }
99
+
100
+ export default TagList;
101
+
102
+ // Zero-arg scenarios for LLD verification
103
+ export const duplicateRejected = () => {
104
+ const tagList = new TagList({ tags: ['hello'], autoRender: false });
105
+ tagList.render();
106
+ const before = tagList.elem.children.length;
107
+ tagList.tagInput.elem.value = 'hello';
108
+ tagList.tagInput.elem.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true }));
109
+ return tagList.elem.children.length === before;
110
+ };
111
+
112
+ export const inputIsLastAfterAdd = () => {
113
+ const tagList = new TagList({ tags: [], autoRender: false });
114
+ tagList.render();
115
+ tagList.tagInput.elem.value = 'new-tag';
116
+ tagList.tagInput.elem.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true }));
117
+ return tagList.elem.lastElementChild === tagList.addTag.elem;
118
+ };
@@ -0,0 +1,30 @@
1
+ # TagList
2
+
3
+ > ./TagList.js
4
+
5
+ Editable tag collection. The key decision: read-only mode removes the editing interface entirely rather than disabling it; a read-only TagList is structurally simpler, not just interaction-blocked.
6
+
7
+ ## Read-only mode produces a different structure, not just disabled controls
8
+
9
+ - `readOnly: true` omits the input and add button entirely; `readOnly: false` includes the full editing interface
10
+ - does a readOnly TagList contain no input element?
11
+ - does a non-readOnly TagList contain both an input and an add button?
12
+
13
+ ## Duplicate tags are silently rejected — adding them does nothing
14
+
15
+ **method:** `duplicateRejected`
16
+
17
+ - attempting to add a tag that already exists leaves the list unchanged; there is no error
18
+ - duplicateRejected() → true
19
+
20
+ ## The add interface stays at the end as tags are added and removed
21
+
22
+ **method:** `inputIsLastAfterAdd`
23
+
24
+ - new tags are inserted before the add-tag input; the input remains the last element automatically
25
+ - inputIsLastAfterAdd() → true
26
+
27
+ ## Cleanup removes the editing interface completely on destroy
28
+
29
+ - the input, button, and popover are destroyed with the component, removing event listeners
30
+ - does destroying the TagList remove its editing interface from the DOM?
@@ -0,0 +1,2 @@
1
+ export { default as TagList } from './TagList';
2
+ export { default as Tag } from './Tag';
@@ -0,0 +1,139 @@
1
+ import { styled } from '../../styled';
2
+ import { Popover } from '../Popover';
3
+
4
+ const StyledPopover = styled(
5
+ Popover,
6
+ ({ colors }) => `
7
+ position: absolute;
8
+ padding: 3px;
9
+ z-index: 1;
10
+ pointer-events: none;
11
+ max-width: 240px;
12
+ margin: auto;
13
+ background-color: ${colors.white};
14
+ color: ${colors.black};
15
+ opacity: 0;
16
+ transform: scaleX(0);
17
+ transition: opacity 0.4s, transform 0.1s, overlay 0.4s allow-discrete, display 0.4s allow-discrete;
18
+ text-indent: 0;
19
+
20
+ &:popover-open {
21
+ opacity: 1;
22
+ transform: scaleX(1);
23
+ transition: overlay 0.4s allow-discrete, display 0.4s allow-discrete, opacity 0.4s, transform 0.4s;
24
+ }
25
+
26
+ @starting-style {
27
+ &:popover-open {
28
+ opacity: 0;
29
+ transform: scaleX(-1);
30
+ }
31
+ }
32
+
33
+ &:before {
34
+ padding: 0 6px 0 3px;
35
+ }
36
+
37
+ &:empty:before {
38
+ padding: 0;
39
+ }
40
+
41
+ &.center {
42
+ top: 2px;
43
+ left: 50%;
44
+ transform: translateX(-50%);
45
+ }
46
+ &.top {
47
+ left: 50%;
48
+ bottom: calc(100% - 3px);
49
+ transform: translateX(-50%);
50
+ }
51
+ &.bottom {
52
+ left: 50%;
53
+ top: calc(100% - 3px);
54
+ transform: translateX(-50%);
55
+ }
56
+ &.left {
57
+ top: 2px;
58
+ right: calc(100% + 2px);
59
+ }
60
+ &.right {
61
+ top: 2px;
62
+ left: calc(100% + 2px);
63
+ }
64
+ &.topLeft {
65
+ right: calc(100% - 9px);
66
+ bottom: calc(100% - 9px);
67
+ }
68
+ &.topRight {
69
+ left: calc(100% - 9px);
70
+ bottom: calc(100% - 9px);
71
+ }
72
+ &.bottomLeft {
73
+ right: calc(100% - 9px);
74
+ top: calc(100% - 9px);
75
+ }
76
+ &.bottomRight {
77
+ left: calc(100% - 9px);
78
+ top: calc(100% - 9px);
79
+ }
80
+ `,
81
+ );
82
+
83
+ const defaultOptions = { position: 'topRight', autoOpen: false };
84
+
85
+ const position_enum = Object.freeze([
86
+ 'center',
87
+ 'top',
88
+ 'bottom',
89
+ 'left',
90
+ 'right',
91
+ 'topLeft',
92
+ 'topRight',
93
+ 'bottomLeft',
94
+ 'bottomRight',
95
+ ]);
96
+
97
+ /**
98
+ * Tooltip popover component with configurable positioning and smooth animations.
99
+ *
100
+ * Extends Popover to provide tooltip-specific styling and positioning options.
101
+ * Supports multiple position presets and animated show/hide transitions.
102
+ * @param {object} [options={}] - Tooltip configuration options
103
+ * @param {('center'|'top'|'bottom'|'left'|'right'|'topLeft'|'topRight'|'bottomLeft'|'bottomRight')} [options.position='topRight'] - Tooltip position relative to parent
104
+ * @param {boolean} [options.autoOpen=false] - Whether tooltip opens automatically
105
+ * @param {string} [options.textContent] - Tooltip text content
106
+ * @param {string} [options.icon] - Icon to display in tooltip
107
+ * @param {...(Component|HTMLElement|string)} children - Child elements to append
108
+ * @returns {Tooltip} Tooltip component instance
109
+ */
110
+ class Tooltip extends StyledPopover {
111
+ position_enum = position_enum;
112
+ defaultOptions = { ...super.defaultOptions, ...defaultOptions };
113
+
114
+ constructor(options = {}, ...children) {
115
+ super(
116
+ {
117
+ ...defaultOptions,
118
+ ...options,
119
+ addClass: ['tooltip'].concat(options.addClass),
120
+ },
121
+ ...children,
122
+ );
123
+ }
124
+
125
+ static handlers = {
126
+ position(value) {
127
+ if (!position_enum.includes(value)) {
128
+ throw new Error(
129
+ `"${value}" is not a valid position. The position must be one of the following values: ${position_enum.join(', ')}`,
130
+ );
131
+ }
132
+
133
+ this.removeClass(...position_enum);
134
+ this.addClass(value);
135
+ },
136
+ };
137
+ }
138
+
139
+ export default Tooltip;
@@ -0,0 +1,22 @@
1
+ # Tooltip
2
+
3
+ > ./Tooltip.js
4
+
5
+ Positioned popover for explanatory text with nine validated position presets. The design decision: `position` is validated immediately on assignment; invalid values fail at the point of use, not when the tooltip is shown.
6
+
7
+ ## Invalid position fails immediately, not on first show
8
+
9
+ - there are nine valid position values; anything else throws at assignment time, not at show time
10
+ - this surfaces misuse at development time rather than producing misaligned tooltips that only appear during interaction
11
+ - does passing an invalid position value throw immediately?
12
+
13
+ ## Changing position removes the previous position class
14
+
15
+ - only one position class is active at a time; switching position replaces, not accumulates
16
+ - callers can safely reassign position without manual cleanup
17
+ - does changing position from one value to another leave only the new position class active?
18
+
19
+ ## Show/hide animation is declared in CSS, not scheduled in JavaScript
20
+
21
+ - the transition is controlled by `:popover-open` and `@starting-style`; the component applies position classes and the platform animates
22
+ - this means animation timing is a CSS concern, not a component lifecycle concern
@@ -0,0 +1 @@
1
+ export { default as Tooltip } from './Tooltip';
@@ -0,0 +1,89 @@
1
+ import { styled } from '../../styled';
2
+ import { Icon } from '../Icon';
3
+ import { Tooltip } from '../Tooltip';
4
+
5
+ const StyledIcon = styled(
6
+ Icon,
7
+ () => `
8
+ &.has-tooltip {
9
+ position: relative;
10
+ display: inline-block;
11
+
12
+ &:hover {
13
+ overflow: visible;
14
+ vertical-align: top;
15
+ }
16
+ }
17
+ `,
18
+ );
19
+
20
+ /**
21
+ * Wrapper component that adds tooltip functionality to any component.
22
+ *
23
+ * Extends Icon to provide automatic tooltip management with hover triggers,
24
+ * positioning, and lifecycle management. Tooltips appear on hover with configurable delay.
25
+ * @param {object} [options={}] - TooltipWrapper configuration options
26
+ * @param {string|object} [options.tooltip] - Tooltip text or configuration object
27
+ * @param {string} [options.icon] - Icon to display in the component
28
+ * @param {...(Component|HTMLElement|string)} children - Child elements to append
29
+ * @returns {TooltipWrapper} TooltipWrapper component instance
30
+ */
31
+ export default class TooltipWrapper extends StyledIcon {
32
+ static handlers = {
33
+ tooltip(value) {
34
+ if (value == null) return;
35
+ const tooltipOptions = typeof value === 'object' ? value : { textContent: value };
36
+
37
+ if (this._tooltip) {
38
+ this._tooltip.setOptions(tooltipOptions);
39
+ } else {
40
+ this._tooltip = new Tooltip({
41
+ appendTo: this.elem,
42
+ ...tooltipOptions,
43
+ });
44
+
45
+ this.addClass('has-tooltip');
46
+
47
+ this.on({
48
+ targetEvent: 'pointerover',
49
+ callback: ({ clientX, clientY }) => {
50
+ this.tooltipTimeout = setTimeout(() => this._tooltip?.show({ x: clientX, y: clientY }), 700);
51
+ },
52
+ });
53
+
54
+ this.on({
55
+ targetEvent: 'pointerout',
56
+ callback: () => {
57
+ clearTimeout(this.tooltipTimeout);
58
+ if (!this.elem.contains(document.activeElement)) this._tooltip?.hide();
59
+ },
60
+ });
61
+
62
+ this.on({
63
+ targetEvent: 'focus',
64
+ callback: () => {
65
+ clearTimeout(this.tooltipTimeout);
66
+ const { left, bottom } = this.elem.getBoundingClientRect();
67
+ this._tooltip?.show({ x: left, y: bottom });
68
+ },
69
+ });
70
+
71
+ this.on({
72
+ targetEvent: 'blur',
73
+ callback: () => {
74
+ clearTimeout(this.tooltipTimeout);
75
+ this._tooltip?.hide();
76
+ },
77
+ });
78
+
79
+ this.replaceCleanup('tooltip', () => {
80
+ if (this._tooltip) {
81
+ this._tooltip.destroy?.();
82
+ this._tooltip = null;
83
+ }
84
+ clearTimeout(this.tooltipTimeout);
85
+ });
86
+ }
87
+ },
88
+ };
89
+ }