@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.
- package/Component/Component.js +598 -0
- package/Component/Component.scenarios.js +88 -0
- package/Component/Component.test.js +717 -0
- package/Component/README.md +455 -0
- package/Component/index.js +3 -0
- package/Component/observeElementConnection.js +52 -0
- package/Component/observeElementConnection.test.js +121 -0
- package/Elem/Elem.js +304 -0
- package/Elem/Elem.test.js +679 -0
- package/Elem/README.md +373 -0
- package/Elem/index.js +1 -0
- package/FontWithASyntaxHighlighter-Regular.woff2 +0 -0
- package/LICENSE +21 -0
- package/README.md +413 -0
- package/components/BottomSheet/BottomSheet.js +192 -0
- package/components/BottomSheet/BottomSheet.lld.md +25 -0
- package/components/BottomSheet/README.md +66 -0
- package/components/BottomSheet/index.js +1 -0
- package/components/Button/Button.js +53 -0
- package/components/Button/Button.lld.md +21 -0
- package/components/Button/index.js +1 -0
- package/components/Calendar/Calendar.js +720 -0
- package/components/Calendar/Calendar.lld.md +22 -0
- package/components/Calendar/CalendarEvent.js +102 -0
- package/components/Calendar/Toolbar.js +78 -0
- package/components/Calendar/index.js +2 -0
- package/components/Calendar/utils.js +56 -0
- package/components/Code/Code.js +84 -0
- package/components/Code/Code.lld.md +21 -0
- package/components/Code/index.js +1 -0
- package/components/ColorPicker/ColorPicker.js +445 -0
- package/components/ColorPicker/ColorPicker.lld.md +21 -0
- package/components/ColorPicker/index.js +1 -0
- package/components/ColorPicker/svg.js +5 -0
- package/components/Dialog/Dialog.js +278 -0
- package/components/Dialog/Dialog.lld.md +20 -0
- package/components/Dialog/README.md +96 -0
- package/components/Dialog/index.js +1 -0
- package/components/Form/Form.js +257 -0
- package/components/Form/Form.lld.md +21 -0
- package/components/Form/README.md +87 -0
- package/components/Form/index.js +1 -0
- package/components/Icon/Icon.js +54 -0
- package/components/Icon/Icon.lld.md +21 -0
- package/components/Icon/index.js +1 -0
- package/components/Input/Input.js +173 -0
- package/components/Input/Input.lld.md +28 -0
- package/components/Input/README.md +97 -0
- package/components/Input/index.js +2 -0
- package/components/Input/utils.js +122 -0
- package/components/Keyboard/Key.js +38 -0
- package/components/Keyboard/Keyboard.js +173 -0
- package/components/Keyboard/Keyboard.lld.md +21 -0
- package/components/Keyboard/index.js +1 -0
- package/components/Label/Label.js +214 -0
- package/components/Label/Label.lld.md +20 -0
- package/components/Label/index.js +1 -0
- package/components/Link/Link.js +43 -0
- package/components/Link/Link.lld.md +15 -0
- package/components/Link/index.js +1 -0
- package/components/List/List.js +82 -0
- package/components/List/List.lld.md +19 -0
- package/components/List/index.js +1 -0
- package/components/Menu/Menu.js +93 -0
- package/components/Menu/Menu.lld.md +15 -0
- package/components/Menu/index.js +1 -0
- package/components/Notify/Notify.js +96 -0
- package/components/Notify/Notify.lld.md +20 -0
- package/components/Notify/index.js +1 -0
- package/components/Page/Page.js +67 -0
- package/components/Page/Page.lld.md +20 -0
- package/components/Page/index.js +1 -0
- package/components/Popover/Popover.js +175 -0
- package/components/Popover/Popover.lld.md +19 -0
- package/components/Popover/index.js +1 -0
- package/components/RadioButton/RadioButton.js +108 -0
- package/components/RadioButton/RadioButton.lld.md +15 -0
- package/components/RadioButton/index.js +1 -0
- package/components/Router/README.md +160 -0
- package/components/Router/Router.js +150 -0
- package/components/Router/Router.lld.md +31 -0
- package/components/Router/View.js +15 -0
- package/components/Router/index.js +2 -0
- package/components/Router/utils.js +17 -0
- package/components/Select/README.md +88 -0
- package/components/Select/Select.js +74 -0
- package/components/Select/Select.lld.md +20 -0
- package/components/Select/index.js +1 -0
- package/components/Table/README.md +94 -0
- package/components/Table/Table.js +171 -0
- package/components/Table/Table.lld.md +21 -0
- package/components/Table/index.js +1 -0
- package/components/TagList/Tag.js +84 -0
- package/components/TagList/TagList.js +118 -0
- package/components/TagList/TagList.lld.md +30 -0
- package/components/TagList/design.excalidraw.png +0 -0
- package/components/TagList/index.js +2 -0
- package/components/Tooltip/Tooltip.js +139 -0
- package/components/Tooltip/Tooltip.lld.md +22 -0
- package/components/Tooltip/index.js +1 -0
- package/components/TooltipWrapper/TooltipWrapper.js +89 -0
- package/components/TooltipWrapper/TooltipWrapper.lld.md +21 -0
- package/components/TooltipWrapper/index.js +1 -0
- package/components/Whiteboard/Whiteboard.js +198 -0
- package/components/Whiteboard/Whiteboard.lld.md +35 -0
- package/components/Whiteboard/index.js +1 -0
- package/components/index.js +27 -0
- package/eslint.config.cjs +118 -0
- package/index.d.ts +635 -0
- package/index.js +19 -0
- package/package.json +123 -0
- package/plugins/asText.js +38 -0
- package/plugins/loadPlugins.js +5 -0
- package/plugins/markdownLoader.js +121 -0
- package/prettier.config.cjs +7 -0
- package/spellcheck.config.cjs +227 -0
- package/styled/README.md +329 -0
- package/styled/appendStyles.js +26 -0
- package/styled/appendStyles.test.js +45 -0
- package/styled/index.js +4 -0
- package/styled/shimCSS.js +31 -0
- package/styled/shimCSS.test.js +103 -0
- package/styled/styled.js +91 -0
- package/styled/styled.test.js +586 -0
- package/styled/themeStyles.js +36 -0
- package/styled/themeStyles.test.js +135 -0
- package/test-setup.js +123 -0
- package/theme/.test.js +69 -0
- package/theme/README.md +607 -0
- package/theme/button.js +100 -0
- package/theme/code.js +123 -0
- package/theme/colors.js +42 -0
- package/theme/fonts.js +42 -0
- package/theme/index.js +33 -0
- package/theme/input.js +64 -0
- package/theme/page.js +208 -0
- package/theme/scrollbar.js +24 -0
- package/theme/table.js +53 -0
- package/utils/README.md +176 -0
- package/utils/browser.js +92 -0
- package/utils/class.js +30 -0
- package/utils/color.js +81 -0
- package/utils/data.js +164 -0
- package/utils/element.js +55 -0
- package/utils/index.js +7 -0
- package/utils/rand.js +12 -0
- 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?
|
|
Binary file
|
|
@@ -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
|
+
}
|