@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,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';