@bunnix/components 0.10.3 → 0.11.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 (149) hide show
  1. package/@types/index.d.ts +179 -15
  2. package/README.md +41 -4
  3. package/package.json +3 -8
  4. package/src/core/buttons.css +1 -0
  5. package/src/core/core.css +16 -2
  6. package/src/core/dialog.css +3 -1
  7. package/src/core/dialog.mjs +101 -16
  8. package/src/core/input.css +202 -0
  9. package/src/core/inputs.mjs +702 -23
  10. package/src/core/layout.mjs +1 -2
  11. package/src/core/media.css +36 -1
  12. package/src/core/media.mjs +13 -13
  13. package/src/core/menu.css +10 -29
  14. package/src/core/menu.mjs +159 -70
  15. package/src/core/outline.mjs +100 -0
  16. package/src/core/sidebar.mjs +189 -68
  17. package/src/core/sliderUtils.mjs +51 -0
  18. package/src/core/table.css +23 -0
  19. package/src/core/table.mjs +35 -20
  20. package/src/core/textareaUtils.mjs +31 -0
  21. package/src/core/utils.mjs +105 -0
  22. package/src/font-face/Framework7Icons-Regular.woff2 +0 -0
  23. package/src/index.mjs +3 -1
  24. package/src/icons/add-circle.svg +0 -1
  25. package/src/icons/add.svg +0 -1
  26. package/src/icons/alt.svg +0 -1
  27. package/src/icons/archive.svg +0 -1
  28. package/src/icons/arrow-down.svg +0 -1
  29. package/src/icons/arrow-left.svg +0 -1
  30. package/src/icons/arrow-right.svg +0 -1
  31. package/src/icons/arrow-up.svg +0 -1
  32. package/src/icons/at.svg +0 -1
  33. package/src/icons/attestation.svg +0 -1
  34. package/src/icons/battery-25.svg +0 -1
  35. package/src/icons/bell.svg +0 -3
  36. package/src/icons/bookmark.svg +0 -1
  37. package/src/icons/bot.svg +0 -1
  38. package/src/icons/bubble.svg +0 -1
  39. package/src/icons/building.svg +0 -3
  40. package/src/icons/button.svg +0 -1
  41. package/src/icons/calculate.svg +0 -1
  42. package/src/icons/calendar.svg +0 -1
  43. package/src/icons/captions-bubble.svg +0 -1
  44. package/src/icons/cart.svg +0 -1
  45. package/src/icons/chart.svg +0 -1
  46. package/src/icons/check.svg +0 -1
  47. package/src/icons/chevron-down.svg +0 -1
  48. package/src/icons/chevron-left.svg +0 -1
  49. package/src/icons/chevron-right.svg +0 -1
  50. package/src/icons/clip.svg +0 -1
  51. package/src/icons/clock.svg +0 -3
  52. package/src/icons/close-circle.svg +0 -3
  53. package/src/icons/close.svg +0 -1
  54. package/src/icons/cloud-download.svg +0 -1
  55. package/src/icons/cloud-upload.svg +0 -1
  56. package/src/icons/cloud.svg +0 -1
  57. package/src/icons/columns-layout.svg +0 -1
  58. package/src/icons/command.svg +0 -1
  59. package/src/icons/cube.svg +0 -1
  60. package/src/icons/delete.svg +0 -3
  61. package/src/icons/dollar.svg +0 -3
  62. package/src/icons/download.svg +0 -1
  63. package/src/icons/draw.svg +0 -1
  64. package/src/icons/duplicate.svg +0 -3
  65. package/src/icons/ear.svg +0 -1
  66. package/src/icons/edit.svg +0 -1
  67. package/src/icons/exclamation-mark.svg +0 -1
  68. package/src/icons/eye-open.svg +0 -1
  69. package/src/icons/eye.svg +0 -1
  70. package/src/icons/file-html.svg +0 -1
  71. package/src/icons/file.svg +0 -3
  72. package/src/icons/finger.svg +0 -1
  73. package/src/icons/flag.svg +0 -1
  74. package/src/icons/folder.svg +0 -1
  75. package/src/icons/function.svg +0 -1
  76. package/src/icons/gear.svg +0 -1
  77. package/src/icons/gift.svg +0 -1
  78. package/src/icons/globe.svg +0 -3
  79. package/src/icons/grid.svg +0 -1
  80. package/src/icons/hammer.svg +0 -1
  81. package/src/icons/hand.svg +0 -1
  82. package/src/icons/hare.svg +0 -1
  83. package/src/icons/heart.svg +0 -3
  84. package/src/icons/home.svg +0 -3
  85. package/src/icons/image.svg +0 -1
  86. package/src/icons/inbox.svg +0 -3
  87. package/src/icons/info.svg +0 -1
  88. package/src/icons/key.svg +0 -1
  89. package/src/icons/lamp.svg +0 -1
  90. package/src/icons/link.svg +0 -1
  91. package/src/icons/location.svg +0 -1
  92. package/src/icons/locker.svg +0 -1
  93. package/src/icons/login.svg +0 -1
  94. package/src/icons/logout.svg +0 -3
  95. package/src/icons/mail.svg +0 -3
  96. package/src/icons/map.svg +0 -3
  97. package/src/icons/markup.svg +0 -1
  98. package/src/icons/merge.svg +0 -1
  99. package/src/icons/more-horizontal.svg +0 -5
  100. package/src/icons/more-vertical.svg +0 -5
  101. package/src/icons/mouse.svg +0 -1
  102. package/src/icons/music-mic.svg +0 -1
  103. package/src/icons/paintbrush.svg +0 -1
  104. package/src/icons/palette.svg +0 -1
  105. package/src/icons/password.svg +0 -1
  106. package/src/icons/pencil.svg +0 -1
  107. package/src/icons/people.svg +0 -3
  108. package/src/icons/percent.svg +0 -1
  109. package/src/icons/person-add.svg +0 -1
  110. package/src/icons/person-remove.svg +0 -1
  111. package/src/icons/person.svg +0 -4
  112. package/src/icons/phone.svg +0 -1
  113. package/src/icons/pin.svg +0 -1
  114. package/src/icons/question-circle.svg +0 -3
  115. package/src/icons/remove-circle.svg +0 -1
  116. package/src/icons/return-arrow.svg +0 -1
  117. package/src/icons/save.svg +0 -1
  118. package/src/icons/search.svg +0 -1
  119. package/src/icons/sections.svg +0 -1
  120. package/src/icons/send.svg +0 -1
  121. package/src/icons/share.svg +0 -1
  122. package/src/icons/shine.svg +0 -1
  123. package/src/icons/sliders.svg +0 -1
  124. package/src/icons/star.svg +0 -3
  125. package/src/icons/staroflife.svg +0 -1
  126. package/src/icons/storage.svg +0 -1
  127. package/src/icons/success-circle.svg +0 -3
  128. package/src/icons/swap.svg +0 -1
  129. package/src/icons/switch.svg +0 -1
  130. package/src/icons/sync.svg +0 -3
  131. package/src/icons/table.svg +0 -3
  132. package/src/icons/tag.svg +0 -3
  133. package/src/icons/terminal.svg +0 -1
  134. package/src/icons/text.svg +0 -1
  135. package/src/icons/thumb-down.svg +0 -1
  136. package/src/icons/thumb-up.svg +0 -1
  137. package/src/icons/timer.svg +0 -3
  138. package/src/icons/toggle.svg +0 -1
  139. package/src/icons/trash.svg +0 -1
  140. package/src/icons/tv-music.svg +0 -1
  141. package/src/icons/update-page.svg +0 -1
  142. package/src/icons/upload.svg +0 -1
  143. package/src/icons/video.svg +0 -1
  144. package/src/icons/wallet.svg +0 -1
  145. package/src/icons/wand-stars.svg +0 -1
  146. package/src/icons/waveform.svg +0 -1
  147. package/src/icons/window.svg +0 -1
  148. package/src/utils/iconRegistry.generated.mjs +0 -187
  149. package/src/utils/iconRegistry.mjs +0 -34
@@ -102,13 +102,12 @@ const GridCore = (props, ...children) => {
102
102
  let layout = props.layout ?? "fixed";
103
103
  let columns = props.columns ?? [];
104
104
  let gap = props.gridGap;
105
+ let style = { ...(props.style ?? {}) };
105
106
 
106
107
  delete props.layout;
107
108
  delete props.columns;
108
109
  delete props.gridGap;
109
110
 
110
- let style = {};
111
-
112
111
  if (gap !== undefined) {
113
112
  style["--grid-gap"] = (typeof gap === "number") ? `${gap}px` : gap;
114
113
  }
@@ -1,8 +1,43 @@
1
+ @font-face {
2
+ font-family: "Framework7 Icons";
3
+ font-style: normal;
4
+ font-weight: 400;
5
+ src: url("../font-face/Framework7Icons-Regular.woff2") format("woff2");
6
+ }
7
+
1
8
  /* Icon Base Styles */
2
9
  .icon {
3
- display: inline-block;
10
+ display: inline-flex;
11
+ align-items: center;
12
+ justify-content: center;
4
13
  width: 22px;
5
14
  height: 22px;
15
+ color: currentColor;
16
+ font-size: 22px;
17
+ line-height: 1;
18
+ flex-shrink: 0;
19
+ }
20
+
21
+ .f7-icons {
22
+ font-family: "Framework7 Icons";
23
+ font-weight: normal;
24
+ font-style: normal;
25
+ font-size: 28px;
26
+ line-height: 1;
27
+ letter-spacing: normal;
28
+ text-transform: none;
29
+ display: inline-block;
30
+ white-space: nowrap;
31
+ word-wrap: normal;
32
+ direction: ltr;
33
+ -webkit-font-smoothing: antialiased;
34
+ text-rendering: optimizeLegibility;
35
+ -moz-osx-font-smoothing: grayscale;
36
+ -webkit-font-feature-settings: "liga";
37
+ -moz-font-feature-settings: "liga=1";
38
+ -moz-font-feature-settings: "liga";
39
+ font-feature-settings: "liga";
40
+ text-align: center;
6
41
  }
7
42
 
8
43
  /* Spinner Base Styles */
@@ -5,14 +5,14 @@
5
5
  *
6
6
  * Components:
7
7
  * - Media: Generic media component that renders images or inline SVG
8
- * - Icon: Icon component using the icon registry
8
+ * - Icon: Icon component using the Framework7 icon font
9
9
  * - Spinner: Animated loading spinner with customizable size
10
10
  * - Avatar: User avatar with support for images or letter initials
11
11
  *
12
12
  * Features:
13
13
  * - Automatic style extraction (width, height, size, etc.)
14
14
  * - Flexible props normalization (supports both props object and direct children)
15
- * - SVG support via innerHTML for icons and spinners
15
+ * - Font-based icon rendering plus inline SVG support for spinners
16
16
  * - Avatar size and appearance customization
17
17
  */
18
18
  import Bunnix, { Show, useState } from "@bunnix/core";
@@ -21,9 +21,8 @@ import {
21
21
  withExtractedStyles,
22
22
  isStateLike,
23
23
  } from "./utils.mjs";
24
- import { iconRegistry } from "../utils/iconRegistry.generated.mjs";
25
24
 
26
- const { span, img } = Bunnix;
25
+ const { span, img, i } = Bunnix;
27
26
 
28
27
  const MediaCore = (props, ...children) => {
29
28
  if ("svg" in props) {
@@ -38,15 +37,16 @@ const MediaCore = (props, ...children) => {
38
37
  const IconCore = (props, ...children) => {
39
38
  const { name, ...restProps } = props;
40
39
  if (!name) return null;
40
+ const style = { ...(restProps.style || {}) };
41
+ if (!style.fontSize && (style.width || style.height)) {
42
+ style.fontSize = style.width || style.height;
43
+ }
41
44
 
42
- const svgContent = iconRegistry[name] || "";
43
- if (!svgContent) return null;
44
-
45
- return span({
45
+ return i({
46
46
  ...restProps,
47
- innerHTML: svgContent,
48
- class: `icon ${restProps.class || ""}`,
49
- });
47
+ style,
48
+ class: `icon f7-icons ${restProps.class || ""}`.trim(),
49
+ }, name);
50
50
  };
51
51
 
52
52
  const spinnerSvg = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
@@ -113,10 +113,10 @@ export const Media = withNormalizedArgs((props, ...children) =>
113
113
  );
114
114
 
115
115
  /**
116
- * Icon component using the icon registry.
116
+ * Icon component using the Framework7 icon font ligatures.
117
117
  *
118
118
  * @param {Object} props - Component props
119
- * @param {string} props.name - Icon name from the icon registry
119
+ * @param {string} props.name - Official Framework7 icon name
120
120
  * @param {number} [props.size=22] - Icon size in pixels
121
121
  * @param {string} [props.color] - Icon color (CSS value)
122
122
  * @param {string} [props.class] - Additional CSS classes
package/src/core/menu.css CHANGED
@@ -5,40 +5,21 @@
5
5
  display: inline-block;
6
6
  }
7
7
 
8
+ /* Popover reset + positioning
9
+ * Uses HTML Popover API — element lives in the CSS Top Layer,
10
+ * fully escaping any overflow: hidden ancestor.
11
+ * Browser UA stylesheet sets inset: 0 and margin: auto on [popover],
12
+ * so both must be reset for our fixed coordinates to take effect. */
8
13
  .menu-items {
9
- position: absolute;
10
- background-color: var(--color-bg-primary);
14
+ position: fixed;
15
+ inset: unset;
16
+ margin: 0;
11
17
  border: 1px solid var(--color-border-primary);
12
18
  border-radius: var(--radius-md);
19
+ background-color: var(--color-bg-primary);
13
20
  min-width: 160px;
14
- z-index: 1000;
15
- overflow: hidden;
16
21
  padding: 4px;
17
- }
18
-
19
- /* Anchor positions */
20
- .menu-items--bottomLeft {
21
- top: 100%;
22
- left: 0;
23
- margin-top: 4px;
24
- }
25
-
26
- .menu-items--bottomRight {
27
- top: 100%;
28
- right: 0;
29
- margin-top: 4px;
30
- }
31
-
32
- .menu-items--topLeft {
33
- bottom: 100%;
34
- left: 0;
35
- margin-bottom: 4px;
36
- }
37
-
38
- .menu-items--topRight {
39
- bottom: 100%;
40
- right: 0;
41
- margin-bottom: 4px;
22
+ overflow: hidden;
42
23
  }
43
24
 
44
25
  .menu-divider {
package/src/core/menu.mjs CHANGED
@@ -8,103 +8,187 @@
8
8
  *
9
9
  * Features:
10
10
  * - Automatic open/close state management
11
- * - Click outside to close
11
+ * - Popover API for overflow-safe rendering (CSS Top Layer)
12
+ * - Browser-native dismiss (click outside, Escape key, page scroll)
12
13
  * - Action items with optional icons
13
14
  * - Divider support for grouping items
14
15
  * - Custom trigger support
15
16
  */
16
- import Bunnix, { useState, useEffect, useRef, Show } from "@bunnix/core";
17
- import { withNormalizedArgs, withExtractedStyles } from "./utils.mjs";
17
+ import Bunnix, { useState, useEffect, useRef, ForEach, Compute } from "@bunnix/core";
18
+ import { withNormalizedArgs, withExtractedStyles, resolveCollectionState } from "./utils.mjs";
18
19
  import { Column, Row } from "./layout.mjs";
19
20
  import { Button } from "./buttons.mjs";
20
21
  import { Icon } from "./media.mjs";
22
+ import { Text } from "./typography.mjs";
21
23
  import "./menu.css";
22
24
 
23
- const { div, button } = Bunnix;
25
+ const { div } = Bunnix;
24
26
 
25
27
  const MenuCore = (props, ...children) => {
26
28
  const isOpen = useState(false);
27
- const dropdownRef = useRef(null);
28
-
29
+ const triggerRef = useRef(null);
30
+ const popoverRef = useRef(null);
31
+
29
32
  // Resolve items (state or raw value)
30
- let items = props.items?.get && props.items?.set
31
- ? props.items.get()
32
- : props.items ?? [];
33
-
33
+ const itemsValue = resolveCollectionState(props.items, []);
34
+ const keyedItemsValue = Compute(itemsValue, (resolvedItems) =>
35
+ (resolvedItems ?? []).map((item, index) =>
36
+ item?.divider && (item.key === undefined || item.key === null)
37
+ ? { ...item, key: `divider-${index}` }
38
+ : item,
39
+ ),
40
+ );
41
+
34
42
  // Resolve trigger
35
43
  let trigger = props.trigger || "Menu";
36
-
37
- // Resolve anchor position
38
- let anchor = props.anchor || "bottomLeft";
39
-
44
+
45
+ // Resolve anchor position (support both kebab-case and camelCase for backward compatibility)
46
+ let anchorInput = props.anchor || "bottom-left";
47
+ const anchorMap = {
48
+ "bottom-left": "bottomLeft",
49
+ "bottom-right": "bottomRight",
50
+ "top-left": "topLeft",
51
+ "top-right": "topRight",
52
+ "bottomLeft": "bottomLeft",
53
+ "bottomRight": "bottomRight",
54
+ "topLeft": "topLeft",
55
+ "topRight": "topRight",
56
+ };
57
+ let anchor = anchorMap[anchorInput] || "bottomLeft";
58
+
40
59
  delete props.items;
41
60
  delete props.trigger;
42
61
  delete props.anchor;
43
-
44
- // Click outside handler
62
+
63
+ // Sync isOpen state from popover toggle events & close on scroll
64
+ // Handles browser auto-dismiss (click outside, Escape key) + scroll-away behavior
65
+ // Uses queueMicrotask to defer listener attachment until after popoverRef is assigned by bunnixToDOM
45
66
  useEffect(() => {
46
- const handleClickOutside = (event) => {
47
- if (isOpen.get() && dropdownRef.current && !dropdownRef.current.contains(event.target)) {
48
- isOpen.set(false);
49
- }
50
- };
51
-
52
- document.addEventListener("mousedown", handleClickOutside);
53
- return () => {
54
- document.removeEventListener("mousedown", handleClickOutside);
55
- };
56
- }, isOpen);
57
-
67
+ queueMicrotask(() => {
68
+ const el = popoverRef.current;
69
+ if (!el) return;
70
+
71
+ // Close menu when page scrolls (capture phase for early detection)
72
+ const handleScroll = () => {
73
+ el.hidePopover();
74
+ };
75
+
76
+ // Sync state when popover opens/closes, manage scroll listener lifecycle
77
+ const handleToggle = (e) => {
78
+ const open = e.newState === "open";
79
+ isOpen.set(open);
80
+ if (open) {
81
+ // Attach scroll listener when menu opens
82
+ window.addEventListener("scroll", handleScroll, true);
83
+ } else {
84
+ // Remove scroll listener when menu closes (click outside, Escape, item click, etc.)
85
+ window.removeEventListener("scroll", handleScroll, true);
86
+ }
87
+ };
88
+
89
+ el.addEventListener("toggle", handleToggle);
90
+ // Toggle listener lives with the element and is GC'd when element is removed
91
+ // Scroll listener is self-managed: attached on open, removed on close
92
+ });
93
+ });
94
+
95
+ const computeMenuPos = (rect, anchor) => {
96
+ const GAP = 4;
97
+ // clientWidth/clientHeight exclude scrollbar width — matches getBoundingClientRect() coordinate space.
98
+ // window.innerWidth/innerHeight include scrollbar and would produce a consistent gap on the right/bottom.
99
+ const vw = document.documentElement.clientWidth;
100
+ const vh = document.documentElement.clientHeight;
101
+ switch (anchor) {
102
+ case "bottomRight":
103
+ return {
104
+ top: `${rect.bottom + GAP}px`,
105
+ left: "auto",
106
+ bottom: "auto",
107
+ right: `${vw - rect.right}px`,
108
+ };
109
+ case "topLeft":
110
+ return {
111
+ top: "auto",
112
+ left: `${rect.left}px`,
113
+ bottom: `${vh - rect.top + GAP}px`,
114
+ right: "auto",
115
+ };
116
+ case "topRight":
117
+ return {
118
+ top: "auto",
119
+ left: "auto",
120
+ bottom: `${vh - rect.top + GAP}px`,
121
+ right: `${vw - rect.right}px`,
122
+ };
123
+ case "bottomLeft":
124
+ default:
125
+ return {
126
+ top: `${rect.bottom + GAP}px`,
127
+ left: `${rect.left}px`,
128
+ bottom: "auto",
129
+ right: "auto",
130
+ };
131
+ }
132
+ };
133
+
58
134
  const toggleDropdown = () => {
59
- isOpen.set(!isOpen.get());
135
+ if (!isOpen.get()) {
136
+ // Measure the trigger element (first child), not the wrapper, for accurate right-edge alignment
137
+ const triggerEl = triggerRef.current.firstElementChild ?? triggerRef.current;
138
+ const rect = triggerEl.getBoundingClientRect();
139
+ const pos = computeMenuPos(rect, anchor);
140
+ if (popoverRef.current) {
141
+ Object.assign(popoverRef.current.style, pos);
142
+ popoverRef.current.showPopover();
143
+ }
144
+ } else {
145
+ popoverRef.current?.hidePopover();
146
+ }
60
147
  };
61
-
148
+
62
149
  const handleItemClick = (item) => {
63
150
  if (item.divider) return;
64
-
65
- isOpen.set(false);
66
-
151
+ popoverRef.current?.hidePopover();
67
152
  if (item.action) {
68
153
  item.action();
69
154
  }
70
155
  };
71
-
156
+
72
157
  return div(
73
- { ...props, class: `menu ${props.class || ""}`, ref: dropdownRef },
158
+ { ...props, class: `menu ${props.class || ""}`, ref: triggerRef },
74
159
  // Trigger
75
- typeof trigger === "function" ? trigger({ isOpen: isOpen.get(), toggle: toggleDropdown }) :
76
- Button(
77
- {
78
- variant: "secondary",
79
- click: toggleDropdown,
80
- },
81
- trigger
82
- ),
83
- // Menu
84
- Show(isOpen, (open) =>
85
- open && div(
86
- { class: `menu-items menu-items--${anchor}` },
87
- Column(
88
- { gap: 0 },
89
- ...items.map((item) => {
90
- if (item.divider) {
91
- return div({ class: "menu-divider" });
92
- }
93
-
94
- return Button(
95
- {
96
- variant: "tertiary",
97
- click: () => handleItemClick(item),
98
- padding: false,
99
- },
100
- Row(
101
- { fillWidth: true, alignItems: "center", gap: "small" },
102
- item.icon && Icon({ name: item.icon, size: 16 }),
103
- item.text || item.key,
104
- ),
105
- );
106
- })
107
- )
160
+ typeof trigger === "function"
161
+ ? trigger({ isOpen: isOpen.get(), toggle: toggleDropdown })
162
+ : Button(
163
+ {
164
+ variant: "secondary",
165
+ click: toggleDropdown,
166
+ },
167
+ trigger
168
+ ),
169
+ // Popover dropdown — always in DOM, shown/hidden via Popover API (CSS Top Layer)
170
+ div(
171
+ { class: "menu-items", popover: "auto", ref: popoverRef },
172
+ Column(
173
+ { gap: 0 },
174
+ ForEach(keyedItemsValue, "key", (item) => {
175
+ if (item.divider) {
176
+ return div({ class: "menu-divider" });
177
+ }
178
+
179
+ return Button(
180
+ {
181
+ variant: "tertiary",
182
+ click: () => handleItemClick(item),
183
+ padding: false,
184
+ },
185
+ Row(
186
+ { fillWidth: true, alignItems: "center", gap: "small" },
187
+ item.icon && Icon({ name: item.icon, size: 16 }),
188
+ Text({ weight: "heavy" }, item.text || item.key),
189
+ ),
190
+ );
191
+ })
108
192
  )
109
193
  ),
110
194
  );
@@ -113,15 +197,20 @@ const MenuCore = (props, ...children) => {
113
197
  /**
114
198
  * Menu component with trigger and action items.
115
199
  *
200
+ * Uses the HTML Popover API (`popover="auto"`) to render the dropdown in
201
+ * the CSS Top Layer — fully escaping any `overflow: hidden` ancestor.
202
+ * Browser provides native dismiss on click-outside and Escape key.
203
+ * Menu also closes automatically when the page scrolls.
204
+ *
116
205
  * @param {Object} props - Component props
117
206
  * @param {Array<Object>} props.items - Menu items array
118
207
  * @param {string} props.items[].key - Unique identifier for the item
119
208
  * @param {string} props.items[].text - Display text for the item
120
- * @param {string} [props.items[].icon] - Optional icon name from registry
209
+ * @param {string} [props.items[].icon] - Optional official Framework7 icon name
121
210
  * @param {Function} [props.items[].action] - Optional action to run on click
122
211
  * @param {boolean} [props.items[].divider] - If true, renders a divider
123
212
  * @param {*} [props.trigger] - Trigger element or function that receives {isOpen, toggle}
124
- * @param {string} [props.anchor="bottomLeft"] - Menu anchor position: "topLeft" | "topRight" | "bottomLeft" | "bottomRight"
213
+ * @param {string} [props.anchor="bottom-left"] - Menu anchor position: "bottom-left" | "bottom-right" | "top-left" | "top-right" (or camelCase variants for backward compatibility)
125
214
  * @param {string} [props.class] - Additional CSS classes
126
215
  * @param {...*} children - Children elements
127
216
  * @returns {*} Menu component
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Outline Component (Disclosure Widget)
3
+ *
4
+ * A collapsible section component with a clickable anchor header and expandable details region.
5
+ *
6
+ * Components:
7
+ * - Outline: Disclosure widget with togglable details
8
+ *
9
+ * Features:
10
+ * - Always-visible anchor header (any Bunnix node)
11
+ * - Collapsible details region (any Bunnix node)
12
+ * - Automatic chevron icon (up/down)
13
+ * - Full layout prop support (gap, padding, margin, width, etc.)
14
+ */
15
+ import Bunnix, { useState, Show } from "@bunnix/core";
16
+ import { withNormalizedArgs, withExtractedStyles } from "./utils.mjs";
17
+ import { Column, Row } from "./layout.mjs";
18
+ import { Icon } from "./media.mjs";
19
+
20
+ const OutlineCore = (props) => {
21
+ let { anchor, details, showChevron = true } = props;
22
+ delete props.anchor;
23
+ delete props.details;
24
+ delete props.showChevron;
25
+
26
+ // Bindable state: accept external StateLike<boolean> or fallback to internal useState
27
+ let showingDetails =
28
+ props.open?.get && props.open?.set
29
+ ? props.open
30
+ : useState(props.open ?? false);
31
+ delete props.open;
32
+
33
+ const handleToggleDetails = () => {
34
+ showingDetails.set(!showingDetails.get());
35
+ };
36
+
37
+ return Column(
38
+ props,
39
+ Row(
40
+ { cursor: "pointer", click: handleToggleDetails, fillWidth: true },
41
+ anchor,
42
+ // Only render chevron if showChevron is true
43
+ showChevron && Show(showingDetails.map((v) => !v), () =>
44
+ Icon({ name: "chevron_down", size: 16, flexShrink: 0 }),
45
+ ),
46
+ showChevron && Show(showingDetails, () =>
47
+ Icon({ name: "chevron_up", size: 16, flexShrink: 0 }),
48
+ ),
49
+ ),
50
+ Show(showingDetails, () => details),
51
+ );
52
+ };
53
+
54
+ /**
55
+ * Outline disclosure component with a togglable details region.
56
+ *
57
+ * Renders a clickable anchor row that expands/collapses a details section.
58
+ * A chevron icon in the anchor row reflects the current open/closed state.
59
+ *
60
+ * @param {Object} props - Component props (also accepts all LayoutProps: gap, padding, margin, width, etc.)
61
+ * @param {*} props.anchor - Always-visible trigger content (any Bunnix node)
62
+ * @param {*} props.details - Collapsible content shown when expanded (any Bunnix node)
63
+ * @param {boolean} [props.showChevron=true] - Whether to render the chevron toggle icon
64
+ * @param {boolean|StateLike<boolean>} [props.open] - Controlled open/closed state; pass a Bunnix State for two-way binding
65
+ * @returns {*} Outline component
66
+ *
67
+ * @example
68
+ * // Basic usage
69
+ * Outline({
70
+ * anchor: Row({ alignItems: "center", gap: "small" },
71
+ * Icon({ name: "doc_text", size: 16 }),
72
+ * Text({ weight: "heavy" }, "Section Title"),
73
+ * ),
74
+ * details: Column({ gap: "small", paddingTop: "small" },
75
+ * Text("Expandable content goes here."),
76
+ * ),
77
+ * });
78
+ *
79
+ * @example
80
+ * // With external state control
81
+ * const outlineState = useState(false);
82
+ * Outline({
83
+ * open: outlineState,
84
+ * anchor: Text({ weight: "heavy" }, "Click to expand"),
85
+ * details: Text("Controlled from outside"),
86
+ * });
87
+ *
88
+ * @example
89
+ * // Without chevron
90
+ * Outline({
91
+ * showChevron: false,
92
+ * anchor: Text({ weight: "heavy" }, "Custom anchor"),
93
+ * details: Text("No automatic chevron"),
94
+ * });
95
+ */
96
+ export const Outline = withNormalizedArgs((props, ...children) =>
97
+ withExtractedStyles((finalProps, ...children) =>
98
+ OutlineCore(finalProps, ...children),
99
+ )(props, ...children),
100
+ );