@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,160 @@
1
+ # Router
2
+
3
+ Hash-based client-side router that maps URL fragments to component classes. Each route pattern maps to a view class; the Router instantiates the matching view and destroys the previous one on navigation.
4
+
5
+ ## Usage
6
+
7
+ ```js
8
+ import { Router, View, Page } from '@vanilla-bean/components';
9
+
10
+ class HomeView extends View {
11
+ build() {
12
+ // render view content here — no Page needed, the shell provides it
13
+ }
14
+ }
15
+
16
+ class UserView extends View {
17
+ build() {
18
+ // route parameters from /users/:id are passed as options
19
+ console.log(this.options.id);
20
+ }
21
+ }
22
+
23
+ class NotFoundView extends View {
24
+ build() {
25
+ // 404 content
26
+ }
27
+ }
28
+
29
+ const page = new Page({ title: 'My App', appendTo: document.body });
30
+
31
+ new Router({
32
+ views: {
33
+ '/': HomeView,
34
+ '/users/:id': UserView,
35
+ },
36
+ defaultPath: '/',
37
+ notFound: NotFoundView,
38
+ appendTo: page,
39
+ });
40
+ ```
41
+
42
+ ## Options
43
+
44
+ | Option | Type | Default | Description |
45
+ | --- | --- | --- | --- |
46
+ | `views` | `object` | — | **Required.** Maps route patterns to component classes. Patterns may contain `:param` segments. |
47
+ | `defaultPath` | `string` | First key of `views` | Route used when the hash is empty and for fallback on unmatched routes. |
48
+ | `notFound` | `typeof Component` | — | Component class rendered when no route matches and `defaultPath` fallback also fails. Receives `{ route }` as an option. |
49
+ | `onRenderView` | `Function` | — | Called with the matched route string whenever a new view is rendered. |
50
+ | `mode` | `'hash' \| 'history'` | `'hash'` | `'hash'` uses URL fragments (`#/path`). `'history'` uses `pushState` and real pathnames (`/path`). History mode requires the server to serve `index.html` for all routes. |
51
+
52
+ ## Properties
53
+
54
+ ```js
55
+ router.path; // string — current URL hash, stripped of '#/', '/', and query strings
56
+ router.path = '/users/42'; // navigate; sets window.location.hash and re-renders
57
+
58
+ router.route; // string — the matched route pattern for the current path (e.g. '/users/:id')
59
+ router.currentRoute; // string — the last successfully rendered route pattern
60
+ ```
61
+
62
+ ## Methods
63
+
64
+ ```js
65
+ router.parseRouteParameters(path?: string): object
66
+ // Extracts named :param segments from a path.
67
+ // Defaults to the current path if none is provided.
68
+ // '/users/42' against '/users/:id' → { id: '42' }
69
+
70
+ router.pathToRoute(path: string): string
71
+ // Returns the matching route pattern for a path, or the path itself if no pattern matches.
72
+ ```
73
+
74
+ ## Route patterns
75
+
76
+ Routes are matched using exact string equality first, then by replacing `:param` segments with a capture regex. When multiple parameterized patterns could match, routes with fewer `:param` segments are tested first, more specific patterns win without relying on object key order.
77
+
78
+ ```js
79
+ views: {
80
+ '/users/new': NewUserView, // exact match — always wins
81
+ '/users/:id': UserView, // one param — tested before /:section/:id
82
+ '/:section/:id': SectionView, // two params — tested last
83
+ }
84
+ ```
85
+
86
+ Query strings are stripped before matching: `#/users/42?sort=name` resolves to `/users/42`.
87
+
88
+ ## Route parameters
89
+
90
+ Segments prefixed with `:` in the pattern are extracted and passed as options to the rendered view class:
91
+
92
+ ```js
93
+ views: { '/users/:id': UserView }
94
+
95
+ // Navigating to #/users/42 renders: new UserView({ id: '42', appendTo: this.elem })
96
+ class UserView extends View {
97
+ build() {
98
+ console.log(this.options.id); // '42'
99
+ }
100
+ }
101
+ ```
102
+
103
+ ## Navigation behavior
104
+
105
+ - **Same-route navigation is a no-op.** Navigating to the already-active route does not destroy and rebuild the view. View state is preserved.
106
+ - **Empty hash** uses `defaultPath`. The hash is set and a re-render is triggered.
107
+ - **Unmatched routes** fall back to `defaultPath` first, then `notFound` if `defaultPath` also fails.
108
+ - The Router listens to both `popstate` and `hashchange`, and the browser back/forward buttons work without any extra setup.
109
+
110
+ ## View base class
111
+
112
+ `View` is a styled `Component` exported alongside `Router`. It fills its container (`width: 100%; height: 100%; display: flex; flex-direction: column; flex: 1`) and is intended as the base class for route views, but is not required. Any `Component` subclass works as a view.
113
+
114
+ ```js
115
+ import { Router, View } from '@vanilla-bean/components';
116
+
117
+ class SettingsView extends View {
118
+ build() {
119
+ /* ... */
120
+ }
121
+ }
122
+ ```
123
+
124
+ ## Example — full SPA shell
125
+
126
+ ```js
127
+ import { Router, View, Nav, NavItem, Page } from '@vanilla-bean/components';
128
+
129
+ class HomeView extends View {
130
+ build() {
131
+ new Component({ textContent: 'Home content', appendTo: this });
132
+ }
133
+ }
134
+
135
+ class ArticleView extends View {
136
+ build() {
137
+ new Component({ textContent: `Article: ${this.options.slug}`, appendTo: this });
138
+ }
139
+ }
140
+
141
+ // Page is the app shell — Router and Nav live inside it, not inside each view
142
+ const page = new Page({ title: 'My App', appendTo: document.body });
143
+
144
+ const router = new Router({
145
+ views: {
146
+ '/': HomeView,
147
+ '/articles/:slug': ArticleView,
148
+ },
149
+ defaultPath: '/',
150
+ appendTo: page,
151
+ });
152
+
153
+ new Nav({
154
+ appendTo: page,
155
+ append: [
156
+ new NavItem({ textContent: 'Home', onPointerPress: () => (router.path = '/') }),
157
+ new NavItem({ textContent: 'Latest', onPointerPress: () => (router.path = '/articles/latest') }),
158
+ ],
159
+ });
160
+ ```
@@ -0,0 +1,150 @@
1
+ import { Component } from '../../Component';
2
+ import { routeToRegex } from './utils';
3
+
4
+ /**
5
+ * Client-side router component with hash-based or history API routing and dynamic view rendering.
6
+ *
7
+ * Provides single-page application routing with support for route parameters,
8
+ * dynamic view rendering, and browser history integration.
9
+ * @param {object} [options={}] - Router configuration options
10
+ * @param {object} options.views - Object mapping route patterns to component classes
11
+ * @param {string} [options.defaultPath] - Default route path, uses first view key if not specified
12
+ * @param {Component} [options.notFound] - Component class for 404/not found routes
13
+ * @param {Function} [options.onRenderView] - Callback fired when rendering a new view
14
+ * @param {'hash'|'history'} [options.mode='hash'] - Routing mode: 'hash' uses URL fragments, 'history' uses pushState
15
+ * @param {...(Component|HTMLElement|string)} children - Child elements to append
16
+ * @returns {Router} Router component instance
17
+ */
18
+ class Router extends Component {
19
+ constructor(options = {}, ...children) {
20
+ super(
21
+ {
22
+ defaultPath: Object.keys(options.views)[0],
23
+ ...options,
24
+ style: {
25
+ display: 'flex',
26
+ flex: 1,
27
+ overflow: 'hidden',
28
+ ...options.style,
29
+ },
30
+ },
31
+ ...children,
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Gets the current path from the URL.
37
+ * @returns {string} Current route path
38
+ */
39
+ get path() {
40
+ if (this.options.mode === 'history') return window.location.pathname;
41
+
42
+ return window.location.hash.replace(/^#\/?/, '/').replace(/\?.*$/, '');
43
+ }
44
+
45
+ /**
46
+ * Sets the current path and triggers view rendering.
47
+ * @param {string} path - New route path to navigate to
48
+ */
49
+ set path(path) {
50
+ if (this.options.mode === 'history') {
51
+ window.history.pushState(null, '', path);
52
+ } else {
53
+ window.location.hash = path;
54
+ }
55
+
56
+ this.renderView();
57
+ }
58
+
59
+ /**
60
+ * Gets the matched route for the current path.
61
+ * @returns {string} Matched route pattern
62
+ */
63
+ get route() {
64
+ return this.pathToRoute(this.path);
65
+ }
66
+
67
+ pathToRoute(path) {
68
+ if (this.options.views[path]) return path;
69
+
70
+ const sorted = Object.keys(this.options.views).sort((a, b) => {
71
+ const aParams = (a.match(/:/g) || []).length;
72
+ const bParams = (b.match(/:/g) || []).length;
73
+ return aParams !== bParams ? aParams - bParams : b.length - a.length;
74
+ });
75
+
76
+ return sorted.find(route => routeToRegex(route).test(path)) || path;
77
+ }
78
+
79
+ /**
80
+ * Extracts route parameters from a path.
81
+ * @param {string} [path] - Path to parse, defaults to current path
82
+ * @returns {object} Object containing route parameters
83
+ */
84
+ parseRouteParameters(path = this.path) {
85
+ const route = this.pathToRoute(path);
86
+ const routeRegex = routeToRegex(route);
87
+ let routeParameterValues = path.match(routeRegex);
88
+ let routeParameterKeys = route.match(routeRegex);
89
+ const parameters = {};
90
+
91
+ if (!routeParameterValues || !routeParameterKeys) return parameters;
92
+
93
+ routeParameterValues = routeParameterValues.slice(1);
94
+ routeParameterKeys = routeParameterKeys.slice(1);
95
+
96
+ routeParameterValues.forEach((parameter, index) => {
97
+ const name = routeParameterKeys[index].slice(1);
98
+
99
+ parameters[name] = parameter;
100
+ });
101
+
102
+ return parameters;
103
+ }
104
+
105
+ build() {
106
+ const reRenderView = () => this.renderView();
107
+
108
+ window.addEventListener('popstate', reRenderView);
109
+ if (this.options.mode !== 'history') window.addEventListener('hashchange', reRenderView);
110
+ this.replaceCleanup('popstate', () => {
111
+ window.removeEventListener('popstate', reRenderView);
112
+ if (this.options.mode !== 'history') window.removeEventListener('hashchange', reRenderView);
113
+ });
114
+
115
+ this.renderView();
116
+ }
117
+
118
+ renderView(route = this.route || this.options.defaultPath) {
119
+ if (this.currentRoute === route) return;
120
+
121
+ this.options.onRenderView?.(route);
122
+
123
+ this.view?.destroy?.();
124
+ this.empty();
125
+
126
+ if (!this.path) return (this.path = route);
127
+
128
+ this.currentRoute = route;
129
+
130
+ if (this.options.views[route]) {
131
+ this.view = new this.options.views[route]({ appendTo: this.elem, ...this.parseRouteParameters() });
132
+ } else if (this.options.notFound) this.view = new this.options.notFound({ appendTo: this.elem, route });
133
+ else if (this.options.defaultPath && this.path !== this.options.defaultPath) this.path = this.options.defaultPath;
134
+ }
135
+ }
136
+
137
+ export default Router;
138
+
139
+ // Zero-arg scenarios for LLD verification
140
+ export const hashStripsQueryString = () => {
141
+ const r = new Router({ views: { '/path': Component }, autoRender: false });
142
+ window.location.hash = '#/path?query=1&sort=name';
143
+ return r.path;
144
+ };
145
+
146
+ export const paramExtractedFromRoute = () => {
147
+ const r = new Router({ views: { '/users/:id': Component }, autoRender: false });
148
+ window.location.hash = '#/users/42';
149
+ return r.parseRouteParameters('/users/42')?.id;
150
+ };
@@ -0,0 +1,31 @@
1
+ # Router
2
+
3
+ > ./Router.js
4
+
5
+ Hash-based router that maps URL fragments to component classes. The design decision: navigating to the current route is a no-op; the active view is not destroyed and rebuilt when the URL doesn't change.
6
+
7
+ ## Query strings are stripped before route matching
8
+
9
+ **method:** `hashStripsQueryString`
10
+
11
+ - `#/path?foo=bar` matches the `/path` route; query strings are stripped before matching so callers write route patterns without accounting for query parameters
12
+ - hashStripsQueryString() → "/path"
13
+
14
+ ## Route parameters are extracted and passed to the rendered view
15
+
16
+ **method:** `paramExtractedFromRoute`
17
+
18
+ - patterns like `/users/:id` match `/users/42` and produce `{ id: '42' }` for the view without any URL parsing at the view level
19
+ - paramExtractedFromRoute() → "42"
20
+
21
+ ## Same-route navigation does not rebuild the view
22
+
23
+ - if the new hash resolves to the same route as what's currently rendered, the component skips re-render
24
+ - this preserves view state across same-route navigations without the caller guarding against them
25
+ - does navigating to the currently active route leave the rendered view unchanged?
26
+
27
+ ## Unmatched routes fall back rather than rendering nothing
28
+
29
+ - an unmatched hash tries `defaultPath` before showing the notFound component
30
+ - the notFound component is only shown when no fallback matches either
31
+ - does navigating to an unknown route eventually show the notFound component?
@@ -0,0 +1,15 @@
1
+ import { Component } from '../../Component';
2
+ import { styled } from '../../styled';
3
+
4
+ const View = styled(
5
+ Component,
6
+ () => `
7
+ height: 100%;
8
+ width: 100%;
9
+ display: flex;
10
+ flex-direction: column;
11
+ flex: 1;
12
+ `,
13
+ );
14
+
15
+ export default View;
@@ -0,0 +1,2 @@
1
+ export { default as Router } from './Router';
2
+ export { default as View } from './View';
@@ -0,0 +1,17 @@
1
+ export const routeToPath = (route, parameters) => {
2
+ let path = route;
3
+
4
+ if (parameters) {
5
+ Object.keys(parameters).forEach(key => {
6
+ path = path.replace(new RegExp(`:${key}`), parameters[key]);
7
+ });
8
+ } else {
9
+ path = path.replaceAll(/\/?:[^/]+/g, '');
10
+ }
11
+
12
+ return path;
13
+ };
14
+
15
+ export const routeToRegex = route => {
16
+ return new RegExp(`^${route.replaceAll(/:[^/]+/g, '([^/]+)')}$`);
17
+ };
@@ -0,0 +1,88 @@
1
+ # Select
2
+
3
+ Select dropdown component extending Input, with dynamic option rendering and enhanced value access for both string and object-based option lists.
4
+
5
+ ## Usage
6
+
7
+ ```js
8
+ import { Select } from '@vanilla-bean/components';
9
+
10
+ const select = new Select({
11
+ options: ['one', 'two', 'three'],
12
+ value: 'two',
13
+ onChange: ({ value }) => console.log('selected:', value),
14
+ });
15
+ ```
16
+
17
+ ## Options
18
+
19
+ Select inherits all options from `Input`. Key additions:
20
+
21
+ | Option | Type | Default | Description |
22
+ | --- | --- | --- | --- |
23
+ | `options` | `Array<string\|object>` | — | List of selectable options. See shape below. Reactive: reassigning `options.options` rebuilds the `<option>` elements |
24
+ | `value` | `any` | — | Currently selected value. Matched against option `value` attributes |
25
+ | `onChange` | `Function` | — | Called with an enhanced event containing `.value` (the selected value string) on change |
26
+
27
+ All other `Input` options (`placeholder`, `validations`, `onInput`, etc.) are available but less commonly used with Select.
28
+
29
+ ### `options` entry shape
30
+
31
+ Each entry in the `options` array is either:
32
+
33
+ - A **string**: used as both the `label` and `value` of the `<option>` element
34
+ - An **object**: spread directly onto the `<option>` element. Common fields:
35
+
36
+ | Field | Type | Description |
37
+ | ---------- | --------- | ----------------------------------------- |
38
+ | `value` | `any` | The value submitted/returned on selection |
39
+ | `label` | `string` | Display text shown in the dropdown |
40
+ | `disabled` | `boolean` | Disables this specific option |
41
+
42
+ ```js
43
+ options: ['plain string', { label: 'Display Text', value: 42 }, { label: 'Unavailable', value: 'x', disabled: true }];
44
+ ```
45
+
46
+ ## Methods
47
+
48
+ ```js
49
+ select.value // getter — returns value, label, or textContent of the selected <option>
50
+ select.value = newVal // setter — sets elem.value directly
51
+
52
+ // Inherited from Input:
53
+ select.validate({ validations?, value? }): Array | undefined
54
+ select.isDirty: boolean
55
+ ```
56
+
57
+ ### Relationship to Input
58
+
59
+ `Select` extends `Input` and overrides `_setOption` only for the `options` key (to rebuild `<option>` children). All other option processing delegates to `Input`, which delegates to `Component`. The `tag` defaults to `'select'` rather than `'input'`. Type auto-detection from value type does not apply.
60
+
61
+ ## Events
62
+
63
+ Select emits standard enhanced DOM events via inherited `Input` behavior. The `onChange` handler receives an event with `.value` set to the currently selected option's value.
64
+
65
+ ## Example
66
+
67
+ Mixed string and object options with a numeric value, used inside a Form:
68
+
69
+ ```js
70
+ import { Select } from '@vanilla-bean/components';
71
+
72
+ const select = new Select({
73
+ options: [
74
+ { label: 'Low', value: 1 },
75
+ { label: 'Medium', value: 2 },
76
+ { label: 'High', value: 3 },
77
+ ],
78
+ value: 2,
79
+ onChange: ({ value }) => {
80
+ select.options.value = value;
81
+ console.log('Priority level:', select.value); // uses getter
82
+ },
83
+ appendTo: document.body,
84
+ });
85
+
86
+ // Swap the option list reactively at any time
87
+ select.options.options = ['alpha', 'beta', 'gamma'];
88
+ ```
@@ -0,0 +1,74 @@
1
+ import { Elem } from '../../Elem';
2
+ import { Input } from '../Input';
3
+
4
+ const defaultOptions = {
5
+ tag: 'select',
6
+ get priorityOptions() {
7
+ return new Set(['textContent', 'content', 'appendTo', 'prependTo', 'options']);
8
+ },
9
+ };
10
+
11
+ /**
12
+ * Select dropdown component extending Input with dynamic option management.
13
+ *
14
+ * Provides enhanced HTML select functionality with dynamic option rendering
15
+ * and improved value handling for both string and object-based options.
16
+ * @param {object} [options={}] - Select configuration options
17
+ * @param {string} [options.tag='select'] - HTML tag, uses select element
18
+ * @param {Array<string|object>} [options.options] - Array of select options
19
+ * @param {*} [options.value] - Currently selected value
20
+ * @param {...(Component|HTMLElement|string)} children - Child elements to append
21
+ * @returns {Select} Select component instance
22
+ */
23
+ class Select extends Input {
24
+ defaultOptions = { ...super.defaultOptions, ...defaultOptions };
25
+
26
+ constructor(options = {}, ...children) {
27
+ super({ ...defaultOptions, ...options }, ...children);
28
+ }
29
+
30
+ static handlers = {
31
+ options(value) {
32
+ this.empty();
33
+
34
+ if (!value) return;
35
+
36
+ for (const option of value) {
37
+ // Optgroup: { label: 'Group', options: [...] }
38
+ if (typeof option === 'object' && Array.isArray(option.options)) {
39
+ const group = document.createElement('optgroup');
40
+ if (option.label) group.label = option.label;
41
+ for (const o of option.options) {
42
+ group.append(new Elem({ tag: 'option', ...(typeof o === 'object' ? o : { label: o, value: o }) }).elem);
43
+ }
44
+ this.elem.append(group);
45
+ } else {
46
+ this.append(
47
+ new Elem({ tag: 'option', ...(typeof option === 'object' ? option : { label: option, value: option }) }),
48
+ );
49
+ }
50
+ }
51
+ },
52
+ };
53
+
54
+ /**
55
+ * Gets the currently selected value with enhanced option handling.
56
+ * @returns {*} Selected option value, label, or text content
57
+ */
58
+ get value() {
59
+ // Use elem.options (HTMLOptionsCollection) — works across optgroups
60
+ const selected = Array.from(this.elem.options).find(({ selected }) => selected);
61
+
62
+ return selected?.value ?? selected?.label ?? selected?.textContent ?? this.elem.value;
63
+ }
64
+
65
+ /**
66
+ * Sets the selected value.
67
+ * @param {*} newValue - Value to select
68
+ */
69
+ set value(newValue) {
70
+ this.elem.value = newValue;
71
+ }
72
+ }
73
+
74
+ export default Select;
@@ -0,0 +1,20 @@
1
+ # Select
2
+
3
+ > ./Select.js
4
+
5
+ Dropdown select that extends Input. The design decision: Select inherits Input's value lifecycle; `isDirty`, validations, and onChange work the same way for a Select as they do for a text Input. Callers don't learn a separate API.
6
+
7
+ ## Select inherits the full Input value contract
8
+
9
+ - `isDirty`, validations, and onChange all work the same way as Input without any Select-specific API
10
+ - does a Select have isDirty?
11
+ - does a Select value change trigger onChange the same way as an Input?
12
+
13
+ ## Options map in order, preserving sequence
14
+
15
+ - items in the `options` array become select options in the same order; no automatic sorting or reindexing
16
+ - does the options array order match the dropdown order?
17
+
18
+ ## The value getter returns the selected option's value, with label as fallback
19
+
20
+ - `select.value` returns the option's `value` attribute; if absent it falls back to `label`, then `textContent`
@@ -0,0 +1 @@
1
+ export { default as Select } from './Select';
@@ -0,0 +1,94 @@
1
+ # Table
2
+
3
+ Data table component with configurable columns, client-side sorting, optional footer rows, and custom cell rendering.
4
+
5
+ ## Usage
6
+
7
+ ```js
8
+ import { Table } from '@vanilla-bean/components';
9
+
10
+ const table = new Table({
11
+ data: [
12
+ { name: 'Alice', score: 42 },
13
+ { name: 'Bob', score: 17 },
14
+ ],
15
+ columns: ['name', 'score'],
16
+ });
17
+ ```
18
+
19
+ ## Options
20
+
21
+ | Option | Type | Default | Description |
22
+ | --- | --- | --- | --- |
23
+ | `data` | `Array<object>` | — | Array of row data objects. Reactive: reassigning `options.data` rebuilds the tbody |
24
+ | `columns` | `Array<string\|object>` | `[]` | Column definitions; see shape below |
25
+ | `footer` | `Array<string\|object>` | — | Footer row cells, same shape as columns. Strings are capitalized automatically |
26
+ | `sortProperty` | `string` | — | Key of the currently sorted column |
27
+ | `sortDirection` | `string` | — | `'asc'` or `'desc'` |
28
+ | `onSort` | `Function` | built-in | Called with `(property, direction)` when a sortable column header is clicked. Default sorts `options.data` in place using `orderBy` |
29
+
30
+ ### `columns` entry shape
31
+
32
+ A column can be a plain string (key name, auto-capitalized as header label) or an object:
33
+
34
+ | Field | Type | Description |
35
+ | --- | --- | --- |
36
+ | `key` | `string` | Property name on each row data object |
37
+ | `content` | `string\|Component` | Header cell content. Defaults to `capitalize(key)` |
38
+ | `sort` | `boolean` | Enables click-to-sort on this column's header with an animated sort icon |
39
+ | `dataColumn` | `object\|Function` | Options forwarded to each `<td>` component, or a `Function({ column, rowData, table })` returning those options |
40
+ | `...thOptions` | `any` | Any additional options forwarded to the `<th>` component (e.g., `style`, `className`) |
41
+
42
+ ### `footer` entry shape
43
+
44
+ Same as columns: a string (rendered as capitalized text content) or an object with any `<td>` component options (e.g., `content`, `colspan`).
45
+
46
+ ## Methods
47
+
48
+ Table does not expose imperative public methods. All mutations go through reactive options:
49
+
50
+ ```js
51
+ // Replace data (rebuilds tbody)
52
+ table.options.data = newDataArray;
53
+
54
+ // Trigger a sort programmatically
55
+ table.options.sortProperty = 'score';
56
+ table.options.sortDirection = 'asc';
57
+ ```
58
+
59
+ Internal references after build:
60
+
61
+ ```js
62
+ table.thead; // Elem wrapping <thead>
63
+ table.tbody; // Elem wrapping <tbody>
64
+ table.tfoot; // Elem wrapping <tfoot>
65
+ ```
66
+
67
+ ## Events
68
+
69
+ Table does not emit custom events. Supply `onSort` to intercept sort interactions.
70
+
71
+ ## Example
72
+
73
+ Sortable table with a custom column label, styled header, and footer totals:
74
+
75
+ ```js
76
+ import { Table } from '@vanilla-bean/components';
77
+ import theme from '@vanilla-bean/components/theme';
78
+
79
+ const table = new Table({
80
+ data: [
81
+ { item: 'Widget A', qty: 5, price: 9.99 },
82
+ { item: 'Widget B', qty: 12, price: 4.49 },
83
+ ],
84
+ columns: [
85
+ { key: 'item', content: 'Product', sort: true },
86
+ { key: 'qty', content: 'Quantity', sort: true, style: { textAlign: 'right' } },
87
+ { key: 'price', content: 'Unit Price', style: { color: theme.colors.green } },
88
+ ],
89
+ footer: [{ content: 'Totals:', colspan: 2 }, { content: '$59.83' }],
90
+ appendTo: document.body,
91
+ });
92
+ ```
93
+
94
+ Clicking a sortable column header toggles direction between `'desc'` and `'asc'`. The `onSort` default mutates `options.data` in place, triggering a reactive tbody rebuild.