@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,93 @@
|
|
|
1
|
+
import { styled } from '../../styled';
|
|
2
|
+
import { List } from '../List';
|
|
3
|
+
|
|
4
|
+
const StyledList = styled(
|
|
5
|
+
List,
|
|
6
|
+
({ colors }) => `
|
|
7
|
+
margin: 0;
|
|
8
|
+
padding: 0;
|
|
9
|
+
list-style: none;
|
|
10
|
+
|
|
11
|
+
& li {
|
|
12
|
+
cursor: pointer;
|
|
13
|
+
padding: 6px 6px 9px 6px;
|
|
14
|
+
border-bottom: 1px solid ${colors.light(colors.gray)};
|
|
15
|
+
|
|
16
|
+
&:last-of-type, &:last-of-type > a {
|
|
17
|
+
border-bottom: none !important;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
&:hover, &:focus, &:focus-visible, & a:hover, & a:focus, & a:focus-visible {
|
|
21
|
+
color: ${colors.light(colors.blue)} !important;
|
|
22
|
+
border-color: ${colors.light(colors.blue)};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
&:focus-visible, & a:focus-visible {
|
|
26
|
+
outline: 2px solid ${colors.light(colors.blue)};
|
|
27
|
+
outline-offset: -2px;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
`,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Menu component for displaying interactive navigation or selection lists.
|
|
35
|
+
*
|
|
36
|
+
* Extends List component with menu-specific styling including hover effects,
|
|
37
|
+
* borders, and click handling. Provides consistent menu appearance and behavior.
|
|
38
|
+
* @param {object} [options={}] - Menu configuration options
|
|
39
|
+
* @param {Array<*>} [options.items] - Array of menu items to render
|
|
40
|
+
* @param {Function} [options.onSelect] - Handler called when menu item is selected
|
|
41
|
+
* @param {Component} [options.ListItemComponent] - Custom component class for rendering menu items
|
|
42
|
+
* @param {...(Component|HTMLElement|string)} children - Child elements to append
|
|
43
|
+
* @returns {Menu} Menu component instance
|
|
44
|
+
*/
|
|
45
|
+
export default class Menu extends StyledList {
|
|
46
|
+
static handlers = {
|
|
47
|
+
items(value, next) {
|
|
48
|
+
next(value);
|
|
49
|
+
if (!value) return;
|
|
50
|
+
const items = Array.from(this.elem.children).filter(el => el.tagName === 'LI');
|
|
51
|
+
items.forEach((li, i) => li.setAttribute('tabindex', i === 0 ? '0' : '-1'));
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
constructor(options = {}, ...children) {
|
|
56
|
+
super({ role: 'menu', ...options, onPointerPress: options.onSelect }, ...children);
|
|
57
|
+
|
|
58
|
+
this.on({
|
|
59
|
+
targetEvent: 'keydown',
|
|
60
|
+
callback: e => {
|
|
61
|
+
const items = Array.from(this.elem.children).filter(el => el.tagName === 'LI');
|
|
62
|
+
if (!items.length) return;
|
|
63
|
+
|
|
64
|
+
const currentIndex = items.indexOf(document.activeElement);
|
|
65
|
+
let next = null;
|
|
66
|
+
|
|
67
|
+
if (e.key === 'ArrowDown') {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
next = items[(currentIndex + 1) % items.length];
|
|
70
|
+
} else if (e.key === 'ArrowUp') {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
next = items[(currentIndex - 1 + items.length) % items.length];
|
|
73
|
+
} else if (e.key === 'Home') {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
next = items[0];
|
|
76
|
+
} else if (e.key === 'End') {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
next = items[items.length - 1];
|
|
79
|
+
} else if ((e.key === 'Enter' || e.key === ' ') && currentIndex >= 0) {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
this.options.onSelect?.(e);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (next) {
|
|
86
|
+
items.forEach(item => item.setAttribute('tabindex', '-1'));
|
|
87
|
+
next.setAttribute('tabindex', '0');
|
|
88
|
+
next.focus();
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Menu
|
|
2
|
+
|
|
3
|
+
> ./Menu.js
|
|
4
|
+
|
|
5
|
+
Styled list whose items trigger an `onSelect` callback. The design decision: Menu routes selection through the same unified pointer+keyboard activation path as Button; keyboard and pointer users get the same experience without separate handlers.
|
|
6
|
+
|
|
7
|
+
## Selecting a menu item works the same way regardless of input method
|
|
8
|
+
|
|
9
|
+
- clicking, touching, Space/Enter, and screen reader activation all invoke `onSelect`; one handler, no separate keyboard path
|
|
10
|
+
- does clicking a menu item invoke onSelect?
|
|
11
|
+
|
|
12
|
+
## Menu renders any item format that List supports
|
|
13
|
+
|
|
14
|
+
- strings, objects with labels, and component instances all work as menu items
|
|
15
|
+
- does a Menu with mixed string and object items render without error?
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Menu } from './Menu';
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { styled } from '../../styled';
|
|
2
|
+
import { Popover } from '../Popover';
|
|
3
|
+
|
|
4
|
+
const type_enum = Object.freeze(['info', 'success', 'warning', 'error']);
|
|
5
|
+
|
|
6
|
+
const StyledPopover = styled(
|
|
7
|
+
Popover,
|
|
8
|
+
({ colors, fonts }) => `
|
|
9
|
+
&:before {
|
|
10
|
+
${fonts.fontAwesomeSolid};
|
|
11
|
+
|
|
12
|
+
position: relative;
|
|
13
|
+
pointer-events: none;
|
|
14
|
+
padding: 0 9px 0 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
&.info {
|
|
18
|
+
background-color: ${colors.blackish(colors.blue)};
|
|
19
|
+
border-color: ${colors.light(colors.blue)};
|
|
20
|
+
|
|
21
|
+
&:before {
|
|
22
|
+
color: ${colors.light(colors.blue)};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
&.success {
|
|
27
|
+
background-color: ${colors.blackish(colors.green)};
|
|
28
|
+
border-color: ${colors.light(colors.green)};
|
|
29
|
+
|
|
30
|
+
&:before {
|
|
31
|
+
color: ${colors.light(colors.green)};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
&.warning {
|
|
36
|
+
background-color: ${colors.blackish(colors.yellow)};
|
|
37
|
+
border-color: ${colors.light(colors.yellow)};
|
|
38
|
+
|
|
39
|
+
&:before {
|
|
40
|
+
color: ${colors.light(colors.yellow)};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
&.error {
|
|
45
|
+
background-color: ${colors.blackish(colors.red)};
|
|
46
|
+
border-color: ${colors.light(colors.red)};
|
|
47
|
+
|
|
48
|
+
&:before {
|
|
49
|
+
color: ${colors.light(colors.red)};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
`,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Notification popover component with type-based styling and auto-dismiss functionality.
|
|
57
|
+
*
|
|
58
|
+
* Extends Popover to provide notification-specific behavior including automatic icons,
|
|
59
|
+
* color-coded styling based on notification type, and optional timeout dismissal.
|
|
60
|
+
* @param {object} [options={}] - Notify configuration options
|
|
61
|
+
* @param {string} [options.type='success'] - Notification type ('info', 'success', 'warning', 'error')
|
|
62
|
+
* @param {string|Component} [options.content] - Notification content to display
|
|
63
|
+
* @param {number} [options.timeout] - Auto-dismiss timeout in milliseconds
|
|
64
|
+
* @param {string} [options.icon] - Custom icon name, defaults to type-appropriate icon
|
|
65
|
+
* @param {number} [options.x] - X position for the notification
|
|
66
|
+
* @param {number} [options.y] - Y position for the notification
|
|
67
|
+
* @param {...(Component|HTMLElement|string)} children - Child elements to append
|
|
68
|
+
* @returns {Notify} Notify component instance
|
|
69
|
+
*/
|
|
70
|
+
export default class Notify extends StyledPopover {
|
|
71
|
+
type_enum = type_enum;
|
|
72
|
+
|
|
73
|
+
constructor(options = {}) {
|
|
74
|
+
const { timeout, type = 'success' } = options;
|
|
75
|
+
const icon =
|
|
76
|
+
options.icon ||
|
|
77
|
+
{ info: 'circle-info', success: 'check', warning: 'triangle-exclamation', error: 'skull-crossbones' }[type];
|
|
78
|
+
|
|
79
|
+
super({
|
|
80
|
+
role: type === 'error' || type === 'warning' ? 'alert' : 'status',
|
|
81
|
+
'aria-atomic': 'true',
|
|
82
|
+
onPointerPress: e => {
|
|
83
|
+
if (!e.target.closest('button, [role="button"]')) this.destroy();
|
|
84
|
+
},
|
|
85
|
+
state: 'manual',
|
|
86
|
+
...options,
|
|
87
|
+
addClass: [type].concat(options.addClass),
|
|
88
|
+
icon,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (timeout) {
|
|
92
|
+
this.timeout = setTimeout(() => this.destroy(), timeout);
|
|
93
|
+
this.addCleanup('timeout', () => clearTimeout(this.timeout));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Notify
|
|
2
|
+
|
|
3
|
+
> ./Notify.js
|
|
4
|
+
|
|
5
|
+
Transient notification that self-destructs. The design choice is that click-to-dismiss is always on; every notification is interactive, not a passive message. The `type` option drives icon and color selection so callers communicate intent rather than manually picking icons.
|
|
6
|
+
|
|
7
|
+
## Type communicates intent; the component selects the appropriate icon
|
|
8
|
+
|
|
9
|
+
- callers pass `type: 'error'` and the component picks the matching icon; a custom `icon` option overrides this only when the standard mapping doesn't fit
|
|
10
|
+
- does a success notification use a visually distinct icon from an error notification?
|
|
11
|
+
|
|
12
|
+
## Clicking anywhere on the notification dismisses it — the whole surface is the target
|
|
13
|
+
|
|
14
|
+
- notifications are temporary and should be easy to clear; there is no separate close button
|
|
15
|
+
- does clicking the notification body remove it from the page?
|
|
16
|
+
|
|
17
|
+
## Manual dismiss cancels a pending timeout — no duplicate destroy fires
|
|
18
|
+
|
|
19
|
+
- when a `timeout` is set and the user dismisses manually before it fires, the timer is cancelled so a second destroy call after the component is already gone cannot happen
|
|
20
|
+
- does manually dismissing a notification before its timeout prevent a second dismiss?
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Notify } from './Notify';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { appendStyles, shimCSS } from '../../styled';
|
|
2
|
+
import { Component } from '../../Component';
|
|
3
|
+
|
|
4
|
+
const dependentStyleSheets = ['@fortawesome/fontawesome-free/css/all.css', '@fontsource-variable/kode-mono/index.css'];
|
|
5
|
+
|
|
6
|
+
shimCSS({ styles: ({ page }) => page });
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Page component that provides full-page layout with automatic stylesheet loading.
|
|
10
|
+
*
|
|
11
|
+
* Serves as a root container for applications with automatic loading of required stylesheets
|
|
12
|
+
* including FontAwesome and fonts. Provides full viewport sizing and flexible layout.
|
|
13
|
+
* @param {object} [options={}] - Page configuration options
|
|
14
|
+
* @param {Array<string|object>} [options.styleSheets=[]] - Additional stylesheets to load
|
|
15
|
+
* @param {object} [options.style] - Additional CSS styles to apply
|
|
16
|
+
* @param {string} [options.autoRender='onload'] - When to auto-render the page
|
|
17
|
+
* @param {...(Component|HTMLElement|string)} children - Child elements to append
|
|
18
|
+
* @returns {Page} Page component instance
|
|
19
|
+
*/
|
|
20
|
+
class Page extends Component {
|
|
21
|
+
constructor(options = {}, ...children) {
|
|
22
|
+
const { styleSheets = [], ...optionsWithoutConfig } = options;
|
|
23
|
+
|
|
24
|
+
[...dependentStyleSheets, ...styleSheets].forEach(styleSheet => {
|
|
25
|
+
if (!styleSheet) return;
|
|
26
|
+
|
|
27
|
+
const { href, scope, id } = typeof styleSheet === 'object' ? styleSheet : { href: styleSheet };
|
|
28
|
+
|
|
29
|
+
if (scope) {
|
|
30
|
+
fetch(href)
|
|
31
|
+
.then(r => r.text())
|
|
32
|
+
.then(css => appendStyles(`${scope} { ${css} }`, id || scope));
|
|
33
|
+
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const existingLink = document.querySelector(`link[rel="stylesheet"][href="${href}"]`);
|
|
38
|
+
|
|
39
|
+
if (existingLink) return;
|
|
40
|
+
|
|
41
|
+
const newLink = document.createElement('link');
|
|
42
|
+
|
|
43
|
+
newLink.rel = 'stylesheet';
|
|
44
|
+
newLink.href = href;
|
|
45
|
+
|
|
46
|
+
document.head.append(newLink);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
super(
|
|
50
|
+
{
|
|
51
|
+
...optionsWithoutConfig,
|
|
52
|
+
style: {
|
|
53
|
+
position: 'relative',
|
|
54
|
+
display: 'flex',
|
|
55
|
+
flexDirection: 'column',
|
|
56
|
+
width: '100%',
|
|
57
|
+
height: '100%',
|
|
58
|
+
...options.style,
|
|
59
|
+
},
|
|
60
|
+
autoRender: 'onload',
|
|
61
|
+
},
|
|
62
|
+
...children,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default Page;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Page
|
|
2
|
+
|
|
3
|
+
> ./Page.js
|
|
4
|
+
|
|
5
|
+
Top-level layout component that takes ownership of stylesheet loading. The design decision: Page loads FontAwesome and typography as part of mounting; application code does not import or configure these separately.
|
|
6
|
+
|
|
7
|
+
## Required stylesheets load once — remounting does not re-fetch
|
|
8
|
+
|
|
9
|
+
- Page checks whether each stylesheet is already present before injecting; remounting in a single-page app does not duplicate link elements or re-trigger network requests
|
|
10
|
+
- does mounting a Page a second time leave the stylesheet count unchanged?
|
|
11
|
+
|
|
12
|
+
## External stylesheets fetch and inject as style content, not link elements
|
|
13
|
+
|
|
14
|
+
- `styleSheets` URLs are fetched at mount time and their content is injected as `<style>` elements, avoiding cross-origin link restrictions
|
|
15
|
+
- does a stylesheet URL's content appear in the document as an inline style element?
|
|
16
|
+
|
|
17
|
+
## Page sets a full-height flex baseline at construction
|
|
18
|
+
|
|
19
|
+
- height: 100% and flex display are applied as inline styles at construction
|
|
20
|
+
- does a Page element have height: 100% applied at construction?
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Page } from './Page';
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { styled } from '../../styled';
|
|
2
|
+
import { Icon } from '../Icon';
|
|
3
|
+
|
|
4
|
+
const StyledIcon = styled(
|
|
5
|
+
Icon,
|
|
6
|
+
({ colors }) => `
|
|
7
|
+
position: absolute;
|
|
8
|
+
background-color: ${colors.darkest(colors.gray)};
|
|
9
|
+
color: ${colors.white};
|
|
10
|
+
padding: 12px;
|
|
11
|
+
border: 1px solid ${colors.lightest(colors.gray)};
|
|
12
|
+
border-radius: 3px;
|
|
13
|
+
margin: 0;
|
|
14
|
+
|
|
15
|
+
&:popover-open {
|
|
16
|
+
display: flex;
|
|
17
|
+
}
|
|
18
|
+
`,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const defaultOptions = {
|
|
22
|
+
uniqueId: true,
|
|
23
|
+
state: 'manual',
|
|
24
|
+
outsideClose: false,
|
|
25
|
+
get viewport() {
|
|
26
|
+
return document.documentElement;
|
|
27
|
+
},
|
|
28
|
+
get appendTo() {
|
|
29
|
+
return document.body;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
const state_enum = Object.freeze(['auto', 'manual']);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Popover component using native HTML popover API with edge-aware positioning.
|
|
36
|
+
*
|
|
37
|
+
* Provides positioned overlay content with automatic edge detection and placement adjustment.
|
|
38
|
+
* Supports manual and automatic dismiss behavior with customizable positioning and sizing.
|
|
39
|
+
* @param {object} [options={}] - Popover configuration options
|
|
40
|
+
* @param {string} [options.state='manual'] - Popover state ('auto', 'manual')
|
|
41
|
+
* @param {boolean} [options.autoOpen=true] - Whether to automatically open on render
|
|
42
|
+
* @param {number} [options.x] - X position for the popover
|
|
43
|
+
* @param {number} [options.y] - Y position for the popover
|
|
44
|
+
* @param {number} [options.maxWidth=264] - Maximum width in pixels
|
|
45
|
+
* @param {number} [options.maxHeight=132] - Maximum height in pixels
|
|
46
|
+
* @param {HTMLElement} [options.viewport] - Viewport element for edge detection
|
|
47
|
+
* @param {string} [options.icon] - Icon to display in the popover
|
|
48
|
+
* @param {string|Component} [options.content] - Popover content
|
|
49
|
+
* @param {...(Component|HTMLElement|string)} children - Child elements to append
|
|
50
|
+
* @returns {Popover} Popover component instance
|
|
51
|
+
*/
|
|
52
|
+
export default class Popover extends StyledIcon {
|
|
53
|
+
defaultOptions = { ...super.defaultOptions, ...defaultOptions };
|
|
54
|
+
state_enum = state_enum;
|
|
55
|
+
|
|
56
|
+
constructor({ autoOpen = true, onConnected: userOnConnected, ...options } = {}, ...children) {
|
|
57
|
+
super(
|
|
58
|
+
{
|
|
59
|
+
...defaultOptions,
|
|
60
|
+
onConnected: () => {
|
|
61
|
+
if (autoOpen) {
|
|
62
|
+
const timeoutId = setTimeout(() => this.show(), 200);
|
|
63
|
+
this.replaceCleanup('autoOpen', () => clearTimeout(timeoutId));
|
|
64
|
+
}
|
|
65
|
+
userOnConnected?.();
|
|
66
|
+
},
|
|
67
|
+
...options,
|
|
68
|
+
},
|
|
69
|
+
...children,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (this.options.x !== undefined && this.options.y !== undefined) this.edgeAwarePlacement(this.options);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static handlers = {
|
|
76
|
+
state(value) {
|
|
77
|
+
this.elem.popover = value;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
edgeAwarePlacement({
|
|
82
|
+
x,
|
|
83
|
+
y,
|
|
84
|
+
maxHeight = this.options.maxHeight ?? 132,
|
|
85
|
+
maxWidth = this.options.maxWidth ?? 264,
|
|
86
|
+
padding = 24,
|
|
87
|
+
viewport = this.options.viewport || this.options.appendTo,
|
|
88
|
+
}) {
|
|
89
|
+
const { left, bottom, right } = (viewport?.elem ?? viewport).getBoundingClientRect();
|
|
90
|
+
|
|
91
|
+
const cursorOffset = 12;
|
|
92
|
+
|
|
93
|
+
let pastRight = x + maxWidth + padding >= right;
|
|
94
|
+
const pastLeft = x - maxWidth + padding <= left;
|
|
95
|
+
const pastBottom = y + maxHeight + padding >= bottom;
|
|
96
|
+
// const pastTop = y - maxHeight + padding <= top;
|
|
97
|
+
|
|
98
|
+
if (pastLeft && pastRight) {
|
|
99
|
+
x = padding;
|
|
100
|
+
pastRight = false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this.elem.style.maxWidth = `${maxWidth}px`;
|
|
104
|
+
this.elem.style.maxHeight = `${maxHeight}px`;
|
|
105
|
+
this.elem.style.top = pastBottom ? 'unset' : `${y + cursorOffset}px`;
|
|
106
|
+
this.elem.style.bottom = pastBottom ? `${document.documentElement.clientHeight + cursorOffset - y}px` : 'unset';
|
|
107
|
+
this.elem.style.left = pastRight ? 'unset' : `${x + cursorOffset}px`;
|
|
108
|
+
this.elem.style.right = pastRight ? `${document.documentElement.clientWidth + cursorOffset - x}px` : 'unset';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Shows the popover with optional position update.
|
|
113
|
+
* @param {object} [options] - Position and sizing options
|
|
114
|
+
*/
|
|
115
|
+
show(options) {
|
|
116
|
+
if (options) this.edgeAwarePlacement(options);
|
|
117
|
+
|
|
118
|
+
if (!this.elem.isConnected) return;
|
|
119
|
+
|
|
120
|
+
this.elem.showPopover();
|
|
121
|
+
|
|
122
|
+
if (this.options.outsideClose) {
|
|
123
|
+
// Cancel any pending registration from a previous show()
|
|
124
|
+
if (this._outsideDismissRaf) cancelAnimationFrame(this._outsideDismissRaf);
|
|
125
|
+
if (this._outsideDismissListener) {
|
|
126
|
+
document.removeEventListener('pointerdown', this._outsideDismissListener, { capture: true });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const onOutsidePress = e => {
|
|
130
|
+
if (!this.elem.contains(e.target)) this.hide();
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
this._outsideDismissListener = onOutsidePress;
|
|
134
|
+
|
|
135
|
+
// Defer one frame so the current press that opened us isn't immediately caught
|
|
136
|
+
this._outsideDismissRaf = requestAnimationFrame(() => {
|
|
137
|
+
this._outsideDismissRaf = null;
|
|
138
|
+
document.addEventListener('pointerdown', onOutsidePress, { capture: true });
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Hides the popover.
|
|
145
|
+
*/
|
|
146
|
+
hide() {
|
|
147
|
+
try {
|
|
148
|
+
this.elem.hidePopover();
|
|
149
|
+
} catch {
|
|
150
|
+
// Popover not shown or not connected
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (this._outsideDismissRaf) {
|
|
154
|
+
cancelAnimationFrame(this._outsideDismissRaf);
|
|
155
|
+
this._outsideDismissRaf = null;
|
|
156
|
+
}
|
|
157
|
+
if (this._outsideDismissListener) {
|
|
158
|
+
document.removeEventListener('pointerdown', this._outsideDismissListener, { capture: true });
|
|
159
|
+
this._outsideDismissListener = null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Checks if the popover is currently open.
|
|
165
|
+
* @returns {boolean} True if popover is open
|
|
166
|
+
*/
|
|
167
|
+
get isOpen() {
|
|
168
|
+
return this.elem.matches(':popover-open');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
destroy() {
|
|
172
|
+
this.hide();
|
|
173
|
+
super.destroy?.();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Popover
|
|
2
|
+
|
|
3
|
+
> ./Popover.js
|
|
4
|
+
|
|
5
|
+
Native popover element with edge-aware placement. The design decision: position is calculated at show time, not at construction; the popover adapts to wherever in the viewport it needs to appear, at the moment it appears.
|
|
6
|
+
|
|
7
|
+
## Placement adapts to viewport edges at the moment of showing
|
|
8
|
+
|
|
9
|
+
- when the popover would overflow an edge, the position flips; this calculation runs on each `show()` call so it stays accurate as the page scrolls or resizes
|
|
10
|
+
|
|
11
|
+
## autoOpen fires after a short delay, not immediately
|
|
12
|
+
|
|
13
|
+
- `autoOpen: true` queues `show()` after 200ms; failing immediately would break when the popover's anchor isn't yet in the DOM
|
|
14
|
+
- does a popover with autoOpen not open synchronously on construction?
|
|
15
|
+
- does cancelling the popover before 200ms prevent it from opening?
|
|
16
|
+
|
|
17
|
+
## Manual and auto state are distinct dismiss models
|
|
18
|
+
|
|
19
|
+
- `state: 'auto'` delegates dismiss to the platform's native light-dismiss; `state: 'manual'` requires explicit `close()`; the choice belongs to the use site
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Popover } from './Popover';
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { styled } from '../../styled';
|
|
2
|
+
import { Component } from '../../Component';
|
|
3
|
+
|
|
4
|
+
const RadioButtonLabel = styled(
|
|
5
|
+
Component,
|
|
6
|
+
({ colors }) => `
|
|
7
|
+
line-height: 1.1;
|
|
8
|
+
display: grid;
|
|
9
|
+
grid-template-columns: 1em auto;
|
|
10
|
+
gap: 0.5em;
|
|
11
|
+
width: fit-content;
|
|
12
|
+
cursor: pointer;
|
|
13
|
+
|
|
14
|
+
&:focus-within {
|
|
15
|
+
color: ${colors.blue};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
& + label {
|
|
19
|
+
margin-top: 1em;
|
|
20
|
+
}
|
|
21
|
+
`,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const RadioButtonInput = styled(
|
|
25
|
+
Component,
|
|
26
|
+
({ colors }) => `
|
|
27
|
+
/* Remove most all native input styles */
|
|
28
|
+
appearance: none;
|
|
29
|
+
|
|
30
|
+
margin: 0;
|
|
31
|
+
font: inherit;
|
|
32
|
+
color: currentColor;
|
|
33
|
+
width: 1.15em;
|
|
34
|
+
height: 1.15em;
|
|
35
|
+
border: 0.15em solid currentColor;
|
|
36
|
+
border-radius: 50%;
|
|
37
|
+
transform: translateY(-0.075em);
|
|
38
|
+
display: grid;
|
|
39
|
+
place-content: center;
|
|
40
|
+
cursor: pointer;
|
|
41
|
+
|
|
42
|
+
&:before {
|
|
43
|
+
content: "";
|
|
44
|
+
width: 0.65em;
|
|
45
|
+
height: 0.65em;
|
|
46
|
+
border-radius: 50%;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
&:checked:before {
|
|
50
|
+
box-shadow: inset 1em 1em ${colors.blue};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
&:focus-visible {
|
|
54
|
+
outline: 2px solid ${colors.blue};
|
|
55
|
+
outline-offset: 2px;
|
|
56
|
+
}
|
|
57
|
+
`,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Radio button group component with custom styled radio inputs.
|
|
62
|
+
*
|
|
63
|
+
* Renders a group of radio button options with consistent styling and behavior.
|
|
64
|
+
* Each radio button is properly labeled and grouped for exclusive selection.
|
|
65
|
+
* @param {object} [options={}] - RadioButton configuration options
|
|
66
|
+
* @param {Array<string|object>} [options.options] - Array of radio button options
|
|
67
|
+
* @param {*} [options.value] - Currently selected value
|
|
68
|
+
* @param {...(Component|HTMLElement|string)} children - Child elements to append
|
|
69
|
+
* @returns {RadioButton} RadioButton component instance
|
|
70
|
+
*/
|
|
71
|
+
class RadioButton extends Component {
|
|
72
|
+
static handlers = {
|
|
73
|
+
options(value) {
|
|
74
|
+
this.empty();
|
|
75
|
+
|
|
76
|
+
if (!value) return;
|
|
77
|
+
|
|
78
|
+
this.append(
|
|
79
|
+
value.map(
|
|
80
|
+
option =>
|
|
81
|
+
new RadioButtonLabel({
|
|
82
|
+
tag: 'label',
|
|
83
|
+
append: [
|
|
84
|
+
new RadioButtonInput({
|
|
85
|
+
tag: 'input',
|
|
86
|
+
type: 'radio',
|
|
87
|
+
value: option?.value || option,
|
|
88
|
+
name: this.uniqueId,
|
|
89
|
+
checked: this.options.value === (option?.value || option),
|
|
90
|
+
onChange: event => {
|
|
91
|
+
if (event.target.checked) this.options.value = event.target.value;
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
option?.label || option,
|
|
95
|
+
],
|
|
96
|
+
}),
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
value(value) {
|
|
101
|
+
this.elem.querySelectorAll('input[type="radio"]').forEach(input => {
|
|
102
|
+
input.checked = input.value === String(value);
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export default RadioButton;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# RadioButton
|
|
2
|
+
|
|
3
|
+
> ./RadioButton.js
|
|
4
|
+
|
|
5
|
+
Radio group from an array of options. The design decision: the HTML `name` coordination that makes radios mutually exclusive is handled automatically; callers pass values and get a working group without managing name attributes.
|
|
6
|
+
|
|
7
|
+
## All radios in the group share one name — mutual exclusivity is provided by the browser
|
|
8
|
+
|
|
9
|
+
- the component generates a shared `name` attribute; the browser enforces that only one radio in the group can be checked at a time
|
|
10
|
+
- does each radio input in the group share the same name attribute?
|
|
11
|
+
|
|
12
|
+
## Options can separate their stored value from their display label
|
|
13
|
+
|
|
14
|
+
- a string option uses its value as both the label and stored datum; an object with `label` and `value` lets them differ, useful when the stored value (an ID, a code) would be confusing as a visible label
|
|
15
|
+
- does an object option with a separate label display the label rather than the raw value?
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as RadioButton } from './RadioButton';
|