@blukavantui/community 1.0.1 → 1.2.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 (304) hide show
  1. package/README.md +11 -5
  2. package/dist/build-audit.json +234 -44
  3. package/dist/checkbox.css +1 -0
  4. package/dist/checkbox.d.ts +2 -0
  5. package/dist/checkbox.js +1 -0
  6. package/dist/chunk-2BSBXDOT.js +1 -0
  7. package/dist/chunk-4LHIQROI.js +0 -0
  8. package/dist/chunk-5ENZVZGF.js +0 -0
  9. package/dist/chunk-6T677NSC.js +1 -0
  10. package/dist/chunk-7CEOCZ5L.js +0 -0
  11. package/dist/chunk-7DXFNK7C.js +1 -0
  12. package/dist/chunk-AL2ETLJV.js +1 -0
  13. package/dist/chunk-BN3PDE5G.js +0 -0
  14. package/dist/chunk-CNIIMGT3.js +0 -0
  15. package/dist/chunk-CSTCEBEK.js +1 -0
  16. package/dist/chunk-DRZM2AL3.js +0 -0
  17. package/dist/chunk-EOKNLCNS.js +1 -0
  18. package/dist/chunk-EPPETNK6.js +1 -0
  19. package/dist/chunk-EPPSBHZR.js +1 -0
  20. package/dist/chunk-EPZGLHPD.js +0 -0
  21. package/dist/chunk-FNX33PDD.js +0 -0
  22. package/dist/{chunk-NTFHNL7T.js → chunk-G6OW4OZM.js} +1 -1
  23. package/dist/chunk-GS67ZBLI.js +1 -0
  24. package/dist/chunk-HO6CYWED.js +0 -0
  25. package/dist/{chunk-6EHKOVZK.js → chunk-HVR2UDFX.js} +1 -1
  26. package/dist/chunk-IJ62ILKE.js +0 -0
  27. package/dist/chunk-IKYBFMHI.js +1 -0
  28. package/dist/chunk-IO22GWON.js +0 -0
  29. package/dist/chunk-KVOA55NE.js +0 -0
  30. package/dist/{chunk-MZBB5ROJ.js → chunk-LF2HELEZ.js} +1 -1
  31. package/dist/chunk-MYVLBALF.js +1 -0
  32. package/dist/{chunk-UEOCR2C3.js → chunk-OLDA73XG.js} +2 -2
  33. package/dist/chunk-RWBLICYX.js +0 -0
  34. package/dist/chunk-RYD6RHRX.js +0 -0
  35. package/dist/chunk-T45FJNEQ.js +1 -0
  36. package/dist/chunk-TZSX4OTV.js +1 -0
  37. package/dist/chunk-UN2SLVFG.js +1 -0
  38. package/dist/{chunk-GK3INPAZ.js → chunk-V2YPIC6Z.js} +1 -1
  39. package/dist/chunk-V4ZQLQ7L.js +0 -0
  40. package/dist/chunk-VAM3FPCS.js +0 -0
  41. package/dist/chunk-VJVFHL6P.js +0 -0
  42. package/dist/chunk-VSTKXZYY.js +0 -0
  43. package/dist/chunk-W3H7XTA7.js +1 -0
  44. package/dist/chunk-WG4US4JH.js +1 -0
  45. package/dist/chunk-WGSTNXAQ.js +0 -0
  46. package/dist/chunk-WJHCVECU.js +1 -0
  47. package/dist/chunk-XVMJG26X.js +1 -0
  48. package/dist/chunk-Y4AXWTAD.js +0 -0
  49. package/dist/chunk-YCLR3IE3.js +0 -0
  50. package/dist/chunk-YLFXSHKZ.js +0 -0
  51. package/dist/chunk-ZDGZGAYG.js +0 -0
  52. package/dist/columns2.css +1 -0
  53. package/dist/columns2.d.ts +2 -0
  54. package/dist/columns2.js +1 -0
  55. package/dist/columns3.css +1 -0
  56. package/dist/columns3.d.ts +2 -0
  57. package/dist/columns3.js +1 -0
  58. package/dist/columns4.css +1 -0
  59. package/dist/columns4.d.ts +2 -0
  60. package/dist/columns4.js +1 -0
  61. package/dist/command-palette.js +1 -1
  62. package/dist/component-motion.css +39 -0
  63. package/dist/component-motion.d.ts +45 -0
  64. package/dist/component-motion.js +1 -0
  65. package/dist/content.js +1 -1
  66. package/dist/core.css +8 -0
  67. package/dist/core.js +1 -1
  68. package/dist/data-motion.css +4 -0
  69. package/dist/data-motion.d.ts +23 -0
  70. package/dist/data-motion.js +1 -0
  71. package/dist/data.css +63 -0
  72. package/dist/data.d.ts +1 -0
  73. package/dist/data.js +1 -1
  74. package/dist/date-picker.css +5 -5
  75. package/dist/date-picker.js +1 -1
  76. package/dist/date-range-picker.css +5 -5
  77. package/dist/date-range-picker.js +1 -1
  78. package/dist/date-time-picker.css +5 -5
  79. package/dist/date-time-picker.js +1 -1
  80. package/dist/draft-components.json +77 -0
  81. package/dist/drawer.js +1 -1
  82. package/dist/essentials.css +8 -0
  83. package/dist/feedback.js +1 -1
  84. package/dist/form-field.css +1 -0
  85. package/dist/form-field.d.ts +2 -0
  86. package/dist/form-field.js +1 -0
  87. package/dist/form-layout/choice.d.ts +15 -0
  88. package/dist/form-layout/layout.d.ts +12 -0
  89. package/dist/form-layout/select.d.ts +3 -0
  90. package/dist/form-layout/shared.d.ts +21 -0
  91. package/dist/form-layout/table.d.ts +20 -0
  92. package/dist/form-layout/text.d.ts +3 -0
  93. package/dist/form-layout/types.d.ts +151 -0
  94. package/dist/form-layout.css +62 -0
  95. package/dist/form-layout.d.ts +12 -0
  96. package/dist/form-layout.js +1 -0
  97. package/dist/index.d.ts +1 -0
  98. package/dist/index.js +1 -1
  99. package/dist/inputs.css +83 -20
  100. package/dist/inputs.d.ts +8 -0
  101. package/dist/inputs.js +1 -1
  102. package/dist/layout.css +63 -0
  103. package/dist/layout.d.ts +3 -0
  104. package/dist/layout.js +1 -1
  105. package/dist/modal.js +1 -1
  106. package/dist/motion/action-feedback.d.ts +14 -0
  107. package/dist/motion/border-glow.d.ts +12 -0
  108. package/dist/motion/button.d.ts +11 -0
  109. package/dist/motion/component-settings.d.ts +51 -0
  110. package/dist/motion/controller.d.ts +33 -0
  111. package/dist/motion/effects.d.ts +30 -0
  112. package/dist/motion/feedback.d.ts +6 -0
  113. package/dist/motion/loading.d.ts +9 -0
  114. package/dist/motion/optional.d.ts +35 -0
  115. package/dist/motion/photo.d.ts +7 -0
  116. package/dist/motion/presence.d.ts +12 -0
  117. package/dist/motion/scene.d.ts +10 -0
  118. package/dist/motion/screen.d.ts +6 -0
  119. package/dist/motion/scroll-reveal.d.ts +13 -0
  120. package/dist/motion/settings-group.d.ts +14 -0
  121. package/dist/motion/stagger.d.ts +12 -0
  122. package/dist/motion/structure.d.ts +18 -0
  123. package/dist/motion/tabs.d.ts +19 -0
  124. package/dist/motion/tokens.d.ts +11 -0
  125. package/dist/motion/value.d.ts +21 -0
  126. package/dist/motion/view-transition.d.ts +14 -0
  127. package/dist/motion-enhancers.d.ts +12 -0
  128. package/dist/motion-enhancers.js +1 -0
  129. package/dist/motion-settings.d.ts +3 -0
  130. package/dist/motion-settings.js +1 -0
  131. package/dist/motion.css +1076 -0
  132. package/dist/motion.d.ts +20 -0
  133. package/dist/motion.js +1 -0
  134. package/dist/native/date-picker.js +1 -1
  135. package/dist/native/date-range-picker.js +1 -1
  136. package/dist/native/date-time-picker.js +1 -1
  137. package/dist/native/form-layout.js +1 -0
  138. package/dist/native/time-picker.js +1 -1
  139. package/dist/native-components/form-layout.d.ts +1 -0
  140. package/dist/native-motion-components.d.ts +3 -0
  141. package/dist/native-motion-content.d.ts +1 -0
  142. package/dist/native-motion-foundations.d.ts +3 -0
  143. package/dist/native-motion-layout.d.ts +3 -0
  144. package/dist/native-motion-pilot.d.ts +34 -0
  145. package/dist/native-motion.d.ts +71 -0
  146. package/dist/notification.js +1 -1
  147. package/dist/pagination.js +1 -1
  148. package/dist/radio-button.css +1 -0
  149. package/dist/radio-button.d.ts +2 -0
  150. package/dist/radio-button.js +1 -0
  151. package/dist/radio-group.css +1 -0
  152. package/dist/radio-group.d.ts +2 -0
  153. package/dist/radio-group.js +1 -0
  154. package/dist/select.css +1 -0
  155. package/dist/select.d.ts +2 -0
  156. package/dist/select.js +1 -0
  157. package/dist/sidebar.js +1 -1
  158. package/dist/sizes.json +416 -112
  159. package/dist/skeleton.css +8 -0
  160. package/dist/styles.css +1213 -20
  161. package/dist/switch.css +1 -0
  162. package/dist/switch.d.ts +2 -0
  163. package/dist/switch.js +1 -0
  164. package/dist/table.css +1 -0
  165. package/dist/table.d.ts +2 -0
  166. package/dist/table.js +1 -0
  167. package/dist/text-area.css +1 -0
  168. package/dist/text-area.d.ts +2 -0
  169. package/dist/text-area.js +1 -0
  170. package/dist/text-input.css +1 -0
  171. package/dist/text-input.d.ts +2 -0
  172. package/dist/text-input.js +1 -0
  173. package/dist/time-picker.css +5 -5
  174. package/dist/time-picker.js +1 -1
  175. package/dist/tree-view.js +1 -1
  176. package/dist/web-motion/chip-ripple.d.ts +6 -0
  177. package/dist/web-motion/choice-ripple.d.ts +6 -0
  178. package/dist/web-motion/content.d.ts +9 -0
  179. package/dist/web-motion/data-rows.d.ts +12 -0
  180. package/dist/web-motion/fields.d.ts +6 -0
  181. package/dist/web-motion/navigation.d.ts +6 -0
  182. package/dist/web-motion/notifications.d.ts +6 -0
  183. package/dist/web-motion/select-ripple.d.ts +6 -0
  184. package/dist/web-motion/statistic.d.ts +13 -0
  185. package/dist/web-motion/surfaces.d.ts +10 -0
  186. package/dist/web-motion/tree.d.ts +6 -0
  187. package/docs/FORM-LAYOUT-ESSENTIALS.md +122 -0
  188. package/docs/PUBLISHING.md +129 -25
  189. package/draft-components.json +77 -0
  190. package/edition.json +44 -7
  191. package/npm-shrinkwrap.json +2 -2
  192. package/package.json +102 -10
  193. package/scripts/build-motion-css.mjs +36 -0
  194. package/scripts/build-motion-styles.mjs +29 -0
  195. package/scripts/build.mjs +20 -8
  196. package/scripts/motion-entries.mjs +2 -0
  197. package/src/checkbox.ts +2 -0
  198. package/src/columns2.ts +2 -0
  199. package/src/columns3.ts +2 -0
  200. package/src/columns4.ts +2 -0
  201. package/src/component-motion.ts +101 -0
  202. package/src/data-motion.ts +60 -0
  203. package/src/data.ts +1 -0
  204. package/src/form-field.ts +2 -0
  205. package/src/form-layout/choice.ts +82 -0
  206. package/src/form-layout/layout.ts +40 -0
  207. package/src/form-layout/select.ts +97 -0
  208. package/src/form-layout/shared.ts +57 -0
  209. package/src/form-layout/table.ts +86 -0
  210. package/src/form-layout/text.ts +73 -0
  211. package/src/form-layout/types.ts +59 -0
  212. package/src/form-layout.ts +12 -0
  213. package/src/index.ts +2 -0
  214. package/src/inputs.ts +8 -0
  215. package/src/layout.ts +3 -0
  216. package/src/motion/action-feedback.ts +38 -0
  217. package/src/motion/border-glow.ts +41 -0
  218. package/src/motion/button.ts +56 -0
  219. package/src/motion/component-settings.ts +147 -0
  220. package/src/motion/controller.ts +191 -0
  221. package/src/motion/effects.ts +75 -0
  222. package/src/motion/feedback.ts +41 -0
  223. package/src/motion/loading.ts +40 -0
  224. package/src/motion/optional.ts +123 -0
  225. package/src/motion/photo.ts +19 -0
  226. package/src/motion/presence.ts +44 -0
  227. package/src/motion/scene.ts +103 -0
  228. package/src/motion/screen.ts +15 -0
  229. package/src/motion/scroll-reveal.ts +49 -0
  230. package/src/motion/settings-group.ts +28 -0
  231. package/src/motion/stagger.ts +26 -0
  232. package/src/motion/structure.ts +75 -0
  233. package/src/motion/tabs.ts +105 -0
  234. package/src/motion/tokens.ts +2 -0
  235. package/src/motion/value.ts +61 -0
  236. package/src/motion/view-transition.ts +57 -0
  237. package/src/motion-enhancers.ts +12 -0
  238. package/src/motion-settings.ts +3 -0
  239. package/src/motion.ts +20 -0
  240. package/src/native-components/form-layout.ts +21 -0
  241. package/src/native-motion-components.ts +76 -0
  242. package/src/native-motion-content.ts +10 -0
  243. package/src/native-motion-foundations.ts +56 -0
  244. package/src/native-motion-layout.ts +35 -0
  245. package/src/native-motion-pilot.ts +151 -0
  246. package/src/native-motion.ts +188 -0
  247. package/src/radio-button.ts +2 -0
  248. package/src/radio-group.ts +2 -0
  249. package/src/runtime/date-picker.js +21 -12
  250. package/src/runtime/date-range-picker.js +21 -12
  251. package/src/runtime/date-time-picker.js +21 -12
  252. package/src/runtime/time-picker.js +21 -12
  253. package/src/select.ts +2 -0
  254. package/src/switch.ts +2 -0
  255. package/src/table.ts +2 -0
  256. package/src/text-area.ts +2 -0
  257. package/src/text-input.ts +2 -0
  258. package/src/types.ts +0 -12
  259. package/src/web-motion/chip-ripple.ts +31 -0
  260. package/src/web-motion/choice-ripple.ts +30 -0
  261. package/src/web-motion/content.ts +74 -0
  262. package/src/web-motion/data-rows.ts +106 -0
  263. package/src/web-motion/fields.ts +52 -0
  264. package/src/web-motion/navigation.ts +62 -0
  265. package/src/web-motion/notifications.ts +67 -0
  266. package/src/web-motion/select-ripple.ts +23 -0
  267. package/src/web-motion/statistic.ts +23 -0
  268. package/src/web-motion/surfaces.ts +137 -0
  269. package/src/web-motion/tree.ts +44 -0
  270. package/styles/checkbox.css +1 -0
  271. package/styles/columns2.css +1 -0
  272. package/styles/columns3.css +1 -0
  273. package/styles/columns4.css +1 -0
  274. package/styles/component-motion.css +39 -0
  275. package/styles/data-motion.css +4 -0
  276. package/styles/date-picker.css +5 -5
  277. package/styles/date-range-picker.css +5 -5
  278. package/styles/date-time-picker.css +5 -5
  279. package/styles/form-field.css +1 -0
  280. package/styles/form-layout.css +62 -0
  281. package/styles/motion-optional.css +15 -0
  282. package/styles/motion-presets.css +92 -0
  283. package/styles/motion-tokens.css +6 -0
  284. package/styles/motion.css +125 -0
  285. package/styles/radio-button.css +1 -0
  286. package/styles/radio-group.css +1 -0
  287. package/styles/select.css +1 -0
  288. package/styles/skeleton.css +8 -0
  289. package/styles/switch.css +1 -0
  290. package/styles/table.css +1 -0
  291. package/styles/text-area.css +1 -0
  292. package/styles/text-input.css +1 -0
  293. package/styles/time-picker.css +5 -5
  294. package/test/component-motion-settings.test.mjs +35 -0
  295. package/test/content-overlay-motion.test.mjs +36 -0
  296. package/test/form-layout.test.mjs +28 -0
  297. package/test/isolation.test.mjs +2 -2
  298. package/test/motion.test.mjs +215 -0
  299. package/dist/chunk-2TCNAQC6.js +0 -1
  300. package/dist/chunk-CTLZIWMX.js +0 -1
  301. package/dist/chunk-MHHJZYXW.js +0 -1
  302. package/dist/chunk-XDYBNO4P.js +0 -1
  303. /package/dist/{chunk-H7ETPJVW.js → chunk-3PODK22R.js} +0 -0
  304. /package/dist/{chunk-SIU7DSOA.js → chunk-KBWB4OIF.js} +0 -0
@@ -0,0 +1,82 @@
1
+ import { el, frame, lifecycle, common, attr, uid } from './shared.js';
2
+ import type { CheckboxOptions, SwitchOptions, RadioButtonOptions, RadioGroupOptions, FieldComponent, FieldEvents } from './types.js';
3
+
4
+ type ToggleOptions = CheckboxOptions & SwitchOptions;
5
+ function toggle(host: HTMLElement, initial: ToggleOptions, events: FieldEvents<boolean>, kind: 'checkbox' | 'switch' | 'radio'): FieldComponent<ToggleOptions, boolean> {
6
+ let p = { ...initial }, initialized = false; const f = frame(host, p), label = el('label', 'blu-choice');
7
+ const input = el('input', 'blu-choice__input'), mark = el('span', 'blu-choice__mark'), text = el('span'), state = el('span', 'blu-choice__state');
8
+ input.type = kind === 'radio' ? 'radio' : 'checkbox'; if (kind === 'switch') input.setAttribute('role', 'switch');
9
+ label.classList.add('blu-choice--' + kind); mark.setAttribute('aria-hidden', 'true'); state.setAttribute('aria-hidden', 'true');
10
+ label.append(input, mark, text, state); f.body.append(label); const life = lifecycle(f.root, reset);
11
+ function update(patch: Partial<ToggleOptions>) {
12
+ life.check(); if (initialized && kind === 'radio' && !('Checked' in patch)) p.Checked = input.checked;
13
+ p = { ...p, ...patch }; f.paint(p, [input]); f.label.hidden = true;
14
+ // The wrapping label names this control. Safari also includes a hidden
15
+ // explicit label in its accessible name if that label keeps its `for`.
16
+ f.label.removeAttribute('for');
17
+ common(input, p); input.checked = !!p.Checked; input.indeterminate = kind === 'checkbox' && !!p.Indeterminate;
18
+ input.value = p.Value || 'on'; attr(input, 'aria-readonly', p.ReadOnly ? 'true' : undefined);
19
+ text.textContent = (p.Label || '') + (p.Required ? ' *' : ''); state.textContent = input.checked ? (p.OnText || '') : (p.OffText || ''); initialized = true;
20
+ }
21
+ function reset() { update({ Checked: !!initial.Checked, Indeterminate: !!initial.Indeterminate }); }
22
+ input.addEventListener('click', event => { if (p.ReadOnly) event.preventDefault(); }, { signal: life.signal });
23
+ input.addEventListener('keydown', event => { if (p.ReadOnly && ['ArrowLeft','ArrowRight','ArrowUp','ArrowDown',' '].includes(event.key)) event.preventDefault(); }, { signal: life.signal });
24
+ input.addEventListener('change', () => { p.Checked = input.checked; p.Indeterminate = false; state.textContent = input.checked ? (p.OnText || '') : (p.OffText || ''); events.Change?.(input.checked); }, { signal: life.signal });
25
+ input.addEventListener('blur', () => events.Blur?.(), { signal: life.signal }); update({});
26
+ return { element: f.root, control: input, update, reset, getValue: () => input.checked, focus: () => input.focus(), validate: () => input.reportValidity(), destroy: life.destroy };
27
+ }
28
+ export function createCheckbox(host: HTMLElement, options: CheckboxOptions = {}, events: FieldEvents<boolean> = {}) { return toggle(host, options, events, 'checkbox'); }
29
+ export function createSwitch(host: HTMLElement, options: SwitchOptions = {}, events: FieldEvents<boolean> = {}) { return toggle(host, options, events, 'switch'); }
30
+ export function createRadioButton(host: HTMLElement, options: RadioButtonOptions, events: FieldEvents<boolean> = {}) { return toggle(host, options, events, 'radio'); }
31
+
32
+ export function createRadioGroup(host: HTMLElement, initial: RadioGroupOptions, events: FieldEvents<string> = {}) {
33
+ let p = { ...initial }, controls: HTMLInputElement[] = [];
34
+ const f = frame(host, p, true), name = p.Name || uid(); const life = lifecycle(f.root, reset);
35
+ function update(patch: Partial<RadioGroupOptions>) {
36
+ life.check();
37
+ // Native bindings resend Options on every value change. Preserve the same
38
+ // inputs when the options are unchanged so keyboard and screen-reader focus
39
+ // stay on the selected choice.
40
+ const replace = !controls.length || (!!patch.Options && (
41
+ patch.Options.length !== p.Options.length || patch.Options.some((option, i) => {
42
+ const previous = p.Options[i];
43
+ return option.Value !== previous.Value || option.Label !== previous.Label ||
44
+ option.Description !== previous.Description || option.Disabled !== previous.Disabled;
45
+ })
46
+ ));
47
+ const focusedValue = controls.find(input => input === document.activeElement)?.value;
48
+ p = { ...p, ...patch };
49
+ if (new Set(p.Options.map(o => o.Value)).size !== p.Options.length) throw Error('Radio option values must be unique.');
50
+ f.body.className = 'blu-radio-group blu-radio-group--' + (p.Direction || 'Vertical').toLowerCase();
51
+ if (replace) {
52
+ f.body.replaceChildren(); controls = p.Options.map((option, i) => {
53
+ const label = el('label', 'blu-choice blu-choice--radio'), input = el('input', 'blu-choice__input'), mark = el('span', 'blu-choice__mark'), copy = el('span');
54
+ input.type = 'radio'; input.id = f.id + '-' + i; input.value = option.Value; mark.setAttribute('aria-hidden', 'true');
55
+ const title = el('span', '', option.Label); title.id = input.id + '-label'; copy.append(title);
56
+ // Keep the option's name separate from its description so screen
57
+ // readers do not announce the description twice via the wrapping label.
58
+ input.setAttribute('aria-labelledby', title.id);
59
+ if (option.Description) { const desc = el('small', 'blu-choice__description', option.Description); desc.id = input.id + '-description'; copy.append(desc); }
60
+ label.append(input, mark, copy); f.body.append(label);
61
+ input.onchange = () => { if (input.checked) { p.Value = input.value; events.Change?.(input.value); } };
62
+ input.onclick = event => { if (p.ReadOnly) event.preventDefault(); };
63
+ input.onkeydown = event => { if (p.ReadOnly && ['ArrowLeft','ArrowRight','ArrowUp','ArrowDown',' '].includes(event.key)) event.preventDefault(); };
64
+ input.onblur = () => events.Blur?.(); return input;
65
+ });
66
+ }
67
+ f.paint(p, controls);
68
+ controls.forEach((input, i) => {
69
+ common(input, p); input.name = p.Name || name; input.disabled = !!p.Disabled || !!p.Options[i].Disabled; input.checked = input.value === p.Value;
70
+ attr(input, 'aria-readonly', p.ReadOnly ? 'true' : undefined);
71
+ if (p.Options[i].Description) attr(input, 'aria-describedby', [input.getAttribute('aria-describedby'), input.id + '-description'].filter(Boolean).join(' '));
72
+ });
73
+ if (replace && focusedValue !== undefined) {
74
+ (controls.find(input => input.value === focusedValue && !input.disabled) ||
75
+ controls.find(input => input.checked && !input.disabled) || controls.find(input => !input.disabled))?.focus();
76
+ }
77
+ }
78
+ function reset() { update({ Value: initial.Value || '' }); } update({});
79
+ return { element: f.root, update, reset, getValue: () => controls.find(c => c.checked)?.value || '',
80
+ focus: () => (controls.find(c => c.checked && !c.disabled) || controls.find(c => !c.disabled))?.focus(),
81
+ validate: () => controls.filter(c => !c.disabled).every(c => c.reportValidity()), destroy: life.destroy };
82
+ }
@@ -0,0 +1,40 @@
1
+ import { el, frame, bound } from './shared.js';
2
+ import type { FormFieldOptions, ColumnsOptions, ColumnsComponent } from './types.js';
3
+
4
+ /** Wrap a consumer-owned control. Destroy restores its original ARIA attributes. */
5
+ export function createFormField(host: HTMLElement, initial: FormFieldOptions = {}) {
6
+ let p = { ...initial }, disposed = false, current: HTMLElement | undefined;
7
+ let original: Record<string, string | null> = {};
8
+ const f = frame(host, p), content = f.body;
9
+ const attributes = ['id', 'aria-describedby', 'aria-invalid', 'aria-required'];
10
+ function restore() { if (current) for (const name of attributes) { const value = original[name]; if (value === null) current.removeAttribute(name); else current.setAttribute(name, value); } }
11
+ function update(patch: Partial<FormFieldOptions>) {
12
+ if (disposed) throw Error('Component is destroyed.'); p = { ...p, ...patch };
13
+ if (p.Control !== current) {
14
+ restore(); content.replaceChildren(); current = p.Control;
15
+ if (current) { original = Object.fromEntries(attributes.map(name => [name, current!.getAttribute(name)])); content.append(current); }
16
+ }
17
+ f.paint(p, current ? [current] : []);
18
+ if (current && original['aria-describedby']) current.setAttribute('aria-describedby', [original['aria-describedby'], current.getAttribute('aria-describedby')].filter(Boolean).join(' '));
19
+ }
20
+ update({}); return { element: f.root, content, update, destroy() { if (disposed) return; disposed = true; restore(); f.root.remove(); } };
21
+ }
22
+
23
+ export function createColumns(host: HTMLElement, count: 2 | 3 | 4, initial: ColumnsOptions = {}): ColumnsComponent {
24
+ let p = { ...initial }, disposed = false; const root = el('div', 'blu-columns');
25
+ const columns = Array.from({ length: count }, (_, i) => { const column = el('div', 'blu-columns__column'); column.dataset.column = String(i + 1); root.append(column); return column; }); host.append(root);
26
+ function update(patch: Partial<ColumnsOptions>) {
27
+ if (disposed) throw Error('Component is destroyed.'); p = { ...p, ...patch };
28
+ const pairs: Record<string, string> = { Equal: 'minmax(0,1fr) minmax(0,1fr)', OneThirdTwoThirds: 'minmax(0,1fr) minmax(0,2fr)', TwoThirdsOneThird: 'minmax(0,2fr) minmax(0,1fr)', OneQuarterThreeQuarters: 'minmax(0,1fr) minmax(0,3fr)', ThreeQuartersOneQuarter: 'minmax(0,3fr) minmax(0,1fr)' };
29
+ if (count === 2 && p.Ratio && !pairs[p.Ratio]) throw Error('Unsupported two-column ratio.');
30
+ if (count === 3 && p.Ratio && !['Equal', 'WideCenter'].includes(p.Ratio)) throw Error('Unsupported three-column ratio.');
31
+ if (count === 4 && p.Ratio && p.Ratio !== 'Equal') throw Error('Four columns use equal widths.');
32
+ root.className = 'blu-columns blu-columns--stack-' + (p.StackAt || 'Medium').toLowerCase() + (p.EqualHeight ? ' blu-columns--equal-height' : '') + (p.ExtendedClass ? ' ' + p.ExtendedClass : '');
33
+ root.style.setProperty('--blu-columns-tracks', count === 2 ? pairs[p.Ratio || 'Equal'] : count === 3 && p.Ratio === 'WideCenter' ? 'minmax(0,1fr) minmax(0,2fr) minmax(0,1fr)' : `repeat(${count}, minmax(0, 1fr))`);
34
+ root.style.setProperty('--blu-columns-gap', bound(p.Gap, 24, 0, 96) + 'px'); root.style.alignItems = p.EqualHeight ? 'stretch' : (p.Align || 'Stretch').toLowerCase();
35
+ }
36
+ update({}); return { element: root, columns, update, destroy() { if (disposed) return; disposed = true; root.remove(); } };
37
+ }
38
+ export const createColumns2 = (host: HTMLElement, options: ColumnsOptions = {}) => createColumns(host, 2, options);
39
+ export const createColumns3 = (host: HTMLElement, options: ColumnsOptions = {}) => createColumns(host, 3, options);
40
+ export const createColumns4 = (host: HTMLElement, options: ColumnsOptions = {}) => createColumns(host, 4, options);
@@ -0,0 +1,97 @@
1
+ import { el, attr, frame, lifecycle, common } from './shared.js';
2
+ import type { SelectOptions, FieldEvents, FieldComponent } from './types.js';
3
+
4
+ /** A styled listbox backed by a form-associated select; the native menu is never shown. */
5
+ export function createSelect(host: HTMLElement, initial: SelectOptions, events: FieldEvents<string> = {}): FieldComponent<SelectOptions, string> {
6
+ let p = { ...initial }, open = false, active = -1, prefix = '', prefixAt = 0;
7
+ const f = frame(host, p), shell = el('div', 'blu-select'), control = el('select', 'blu-select__native');
8
+ const trigger = el('button', 'blu-select__trigger'), clear = el('button', 'blu-text__action', '×'), popup = el('div', 'blu-select__popup');
9
+ const search = el('input', 'blu-select__search'), list = el('div', 'blu-select__list'), empty = el('p', 'blu-field__help', 'No matching options');
10
+ control.tabIndex = -1; control.setAttribute('aria-hidden', 'true'); control.dataset.enhanced = 'true';
11
+ trigger.type = clear.type = 'button'; trigger.id = f.id; trigger.setAttribute('role', 'combobox'); trigger.setAttribute('aria-haspopup', 'listbox');
12
+ list.id = f.id + '-list'; list.setAttribute('role', 'listbox'); trigger.setAttribute('aria-controls', list.id);
13
+ search.type = 'search'; search.placeholder = 'Search options'; search.setAttribute('role', 'combobox'); search.setAttribute('aria-autocomplete', 'list'); search.setAttribute('aria-controls', list.id);
14
+ popup.hidden = true; popup.append(search, list, empty); shell.append(control, trigger, clear, popup); f.body.append(shell);
15
+ const hasPopover = typeof popup.showPopover === 'function'; if (hasPopover) popup.setAttribute('popover', 'manual');
16
+ const life = lifecycle(f.root, reset); let visible: number[] = [];
17
+ function position() {
18
+ if (!open || !hasPopover) return; const r = trigger.getBoundingClientRect();
19
+ const below = window.innerHeight - r.bottom - 12, above = r.top - 12;
20
+ const up = below < 220 && above > below, height = Math.min(320, Math.max(100, up ? above : below));
21
+ popup.style.cssText = `position:fixed;inset:auto;margin:0;left:${Math.max(8, Math.min(r.left, window.innerWidth - r.width - 8))}px;width:${Math.min(r.width, window.innerWidth - 16)}px;max-height:${height}px;${up ? 'bottom:' + (window.innerHeight - r.top + 6) : 'top:' + (r.bottom + 6)}px;`;
22
+ }
23
+ function activeOption(index: number) {
24
+ active = index; list.querySelectorAll<HTMLElement>('[role=option]').forEach(node => {
25
+ const isActive = Number(node.dataset.index) === active; node.dataset.active = String(isActive);
26
+ if (isActive) node.scrollIntoView({ block: 'nearest' });
27
+ });
28
+ const id = active < 0 ? undefined : f.id + '-option-' + active;
29
+ attr(trigger, 'aria-activedescendant', open ? id : undefined); attr(search, 'aria-activedescendant', open ? id : undefined);
30
+ }
31
+ function renderOptions() {
32
+ visible = p.Options.map((_, i) => i).filter(i => p.Options[i].Label.toLocaleLowerCase().includes(search.value.toLocaleLowerCase()));
33
+ list.replaceChildren();
34
+ for (const i of visible) {
35
+ const o = p.Options[i], row = el('div', 'blu-select__option', o.Label); row.id = f.id + '-option-' + i;
36
+ row.setAttribute('role', 'option'); row.dataset.index = String(i); row.setAttribute('aria-selected', String(o.Value === control.value));
37
+ row.setAttribute('aria-disabled', String(!!o.Disabled)); if (o.Description) row.append(el('small', 'blu-choice__description', o.Description));
38
+ row.onpointerdown = event => event.preventDefault(); row.onclick = () => { if (!o.Disabled) choose(i); }; list.append(row);
39
+ }
40
+ empty.hidden = !!visible.length;
41
+ const enabled = visible.filter(i => !p.Options[i].Disabled);
42
+ activeOption(enabled.includes(active) ? active : (enabled.find(i => p.Options[i].Value === control.value) ?? enabled[0] ?? -1));
43
+ }
44
+ function show(value: boolean, focus = true) {
45
+ if (value && (p.Disabled || p.ReadOnly)) return;
46
+ open = value; trigger.setAttribute('aria-expanded', String(open)); search.setAttribute('aria-expanded', String(open));
47
+ if (open) { popup.hidden = false; if (hasPopover && !popup.matches(':popover-open')) popup.showPopover(); search.value = ''; active = -1; renderOptions(); position(); if (p.Searchable) search.focus(); }
48
+ else { if (hasPopover && popup.matches(':popover-open')) popup.hidePopover(); popup.hidden = true; activeOption(-1); if (focus) trigger.focus(); }
49
+ }
50
+ function choose(index: number) { p.Value = index < 0 ? '' : p.Options[index].Value; control.value = p.Value; paintValue(); show(false); events.Change?.(p.Value); }
51
+ function paintValue() {
52
+ trigger.textContent = p.Options.find(o => o.Value === control.value)?.Label || p.Placeholder || 'Choose an option';
53
+ trigger.dataset.placeholder = String(!control.value); clear.hidden = !p.Clearable || !control.value || !!p.ReadOnly;
54
+ if (control.validity.valid && !p.Error) { f.error.hidden = true; f.error.textContent = ''; f.paint(p, [trigger]); }
55
+ }
56
+ function update(patch: Partial<SelectOptions>) {
57
+ life.check(); p = { ...p, ...patch };
58
+ if (p.Options.some(o => !o.Value) || new Set(p.Options.map(o => o.Value)).size !== p.Options.length) throw Error('Select option values must be non-empty and unique.');
59
+ common(control, p); f.paint(p, [trigger]); control.id = f.id + '-native';
60
+ const placeholder = el('option', '', p.Placeholder || 'Choose an option'); placeholder.value = '';
61
+ control.replaceChildren(placeholder, ...p.Options.map(o => { const option = el('option', '', o.Label); option.value = o.Value; option.disabled = !!o.Disabled; return option; }));
62
+ control.value = p.Options.some(o => o.Value === p.Value) ? p.Value! : ''; p.Value = control.value;
63
+ trigger.disabled = clear.disabled = !!p.Disabled; attr(trigger, 'aria-readonly', p.ReadOnly ? 'true' : undefined);
64
+ clear.setAttribute('aria-label', 'Clear ' + (p.Label || 'selection')); search.setAttribute('aria-label', 'Search ' + (p.Label || 'options'));
65
+ list.setAttribute('aria-label', p.Label || 'Options'); search.hidden = !p.Searchable;
66
+ if (p.Disabled || p.ReadOnly) show(false, false); else if (open) { renderOptions(); position(); }
67
+ trigger.setAttribute('aria-expanded', String(open)); paintValue();
68
+ }
69
+ function key(event: KeyboardEvent) {
70
+ if (p.Disabled || p.ReadOnly) return;
71
+ const enabled = visible.filter(i => !p.Options[i].Disabled), from = enabled.indexOf(active);
72
+ if (event.key === 'Escape' && open) { event.preventDefault(); show(false); }
73
+ else if (event.key === 'Tab') show(false, false);
74
+ else if (['ArrowDown','ArrowUp','Home','End'].includes(event.key)) {
75
+ if (!open) { event.preventDefault(); show(true); return; }
76
+ event.preventDefault();
77
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? enabled.length - 1 : Math.max(0, Math.min(enabled.length - 1, from + (event.key === 'ArrowDown' ? 1 : -1)));
78
+ activeOption(enabled[next] ?? -1);
79
+ } else if (event.key === 'Enter' || (event.key === ' ' && event.target !== search)) {
80
+ event.preventDefault(); if (!open) show(true); else if (active >= 0) choose(active);
81
+ } else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && event.target !== search) {
82
+ event.preventDefault(); if (!open) show(true);
83
+ prefix = Date.now() - prefixAt > 650 ? event.key : prefix + event.key; prefixAt = Date.now();
84
+ const next = visible.find(i => !p.Options[i].Disabled && p.Options[i].Label.toLocaleLowerCase().startsWith(prefix.toLocaleLowerCase())); if (next !== undefined) activeOption(next);
85
+ }
86
+ }
87
+ function reset() { update({ Value: initial.Value || '' }); show(false, false); }
88
+ trigger.addEventListener('click', () => show(!open), { signal: life.signal }); clear.addEventListener('click', () => choose(-1), { signal: life.signal });
89
+ shell.addEventListener('keydown', key, { signal: life.signal }); search.addEventListener('input', renderOptions, { signal: life.signal });
90
+ document.addEventListener('pointerdown', e => { if (open && !f.root.contains(e.target as Node)) show(false, false); }, { signal: life.signal });
91
+ shell.addEventListener('focusout', e => { if (!shell.contains(e.relatedTarget as Node)) { show(false, false); events.Blur?.(); } }, { signal: life.signal });
92
+ window.addEventListener('resize', position, { signal: life.signal }); window.addEventListener('scroll', position, { signal: life.signal, capture: true });
93
+ control.addEventListener('invalid', e => { e.preventDefault(); attr(trigger, 'aria-invalid', 'true'); f.error.textContent = control.validationMessage; f.error.hidden = false; attr(trigger, 'aria-describedby', f.error.id); trigger.focus(); }, { signal: life.signal });
94
+ control.addEventListener('change', () => { p.Value = control.value; paintValue(); events.Change?.(control.value); }, { signal: life.signal });
95
+ update({});
96
+ return { element: f.root, control, update, reset, getValue: () => control.value, focus: () => trigger.focus(), validate: () => control.reportValidity(), destroy() { show(false, false); life.destroy(); } };
97
+ }
@@ -0,0 +1,57 @@
1
+ import type { FieldOptions } from './types.js';
2
+ export function el<K extends keyof HTMLElementTagNameMap>(tag: K, cls = '', text?: string) {
3
+ const node = document.createElement(tag); node.className = cls;
4
+ if (text !== undefined) node.textContent = text;
5
+ return node;
6
+ }
7
+ let sequence = 0;
8
+ export function uid() { return 'blu-field-' + (++sequence) + '-' + Math.random().toString(36).slice(2, 8); }
9
+ export function attr(node: Element, name: string, value?: string | number | boolean) {
10
+ if (value === undefined || value === false || value === '') node.removeAttribute(name);
11
+ else node.setAttribute(name, String(value));
12
+ }
13
+ export function frame(host: HTMLElement, options: FieldOptions, group = false) {
14
+ const root = el(group ? 'fieldset' : 'div', 'blu-field');
15
+ const label = el(group ? 'legend' : 'label', 'blu-field__label');
16
+ const body = el('div', 'blu-field__body'), description = el('div', 'blu-field__help'), error = el('div', 'blu-field__error');
17
+ const id = options.Id || uid(); description.id = id + '-help'; error.id = id + '-error'; error.setAttribute('aria-live', 'polite');
18
+ root.append(label, body, description, error); host.append(root);
19
+ function paint(p: FieldOptions, controls: HTMLElement[]) {
20
+ root.className = 'blu-field blu-field--' + (p.Size || 'Medium').toLowerCase() + (p.ExtendedClass ? ' ' + p.ExtendedClass : '');
21
+ root.dataset.invalid = String(!!p.Error); root.dataset.disabled = String(!!p.Disabled);
22
+ label.textContent = (p.Label || '') + (p.Required ? ' *' : ''); label.hidden = !p.Label;
23
+ if (!group && controls[0]) { controls[0].id ||= id; label.setAttribute('for', controls[0].id); }
24
+ description.textContent = p.Description || ''; description.hidden = !p.Description;
25
+ error.textContent = p.Error || ''; error.hidden = !p.Error;
26
+ const described = [p.Description ? description.id : '', p.Error ? error.id : ''].filter(Boolean).join(' ');
27
+ for (const control of controls) {
28
+ attr(control, 'aria-describedby', described); attr(control, 'aria-invalid', p.Error ? 'true' : undefined);
29
+ attr(control, 'aria-required', p.Required ? 'true' : undefined);
30
+ }
31
+ }
32
+ return { root, label, body, description, error, id, paint };
33
+ }
34
+ export function lifecycle(root: HTMLElement, reset: () => void) {
35
+ const abort = new AbortController(); let disposed = false;
36
+ // Native form reset is cancelable and restores values after event dispatch.
37
+ const form = root.closest('form');
38
+ form?.addEventListener('reset', event => queueMicrotask(() => { if (!disposed && !event.defaultPrevented) reset(); }), { signal: abort.signal });
39
+ return {
40
+ signal: abort.signal,
41
+ check() { if (disposed) throw Error('Component is destroyed.'); },
42
+ destroy() { if (disposed) return; disposed = true; abort.abort(); root.remove(); },
43
+ };
44
+ }
45
+ export function common(control: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, p: FieldOptions) {
46
+ control.name = p.Name || ''; control.disabled = !!p.Disabled; control.required = !!p.Required;
47
+ if ('readOnly' in control) control.readOnly = !!p.ReadOnly;
48
+ control.setCustomValidity(p.Error || '');
49
+ }
50
+ export function bound(value: number | undefined, fallback: number, min: number, max: number) {
51
+ return Number.isFinite(value) ? Math.max(min, Math.min(max, value!)) : fallback;
52
+ }
53
+ export function imageUrl(value: string) {
54
+ const url = new URL(value, document.baseURI);
55
+ if (!['http:', 'https:'].includes(url.protocol)) throw Error('Adornment images must use an HTTP(S) or relative URL.');
56
+ return url.href;
57
+ }
@@ -0,0 +1,86 @@
1
+ import { el, bound, uid } from './shared.js';
2
+ import type { TableOptions, TableEvents } from './types.js';
3
+
4
+ export function tableRows<Row extends object>(options: TableOptions<Row>) {
5
+ const rows = [...options.Rows], key = options.SortKey;
6
+ if (key && options.Columns.some(c => c.Key === key && c.Sortable)) rows.sort((a, b) => {
7
+ const x = a[key], y = b[key]; if (x == null || y == null) return x == null ? (y == null ? 0 : 1) : -1;
8
+ const difference = typeof x === 'number' && typeof y === 'number' ? x - y : String(x).localeCompare(String(y), undefined, { numeric: true, sensitivity: 'base' });
9
+ return options.SortDirection === 'Descending' ? -difference : difference;
10
+ });
11
+ const size = options.PageSize ? Math.floor(bound(options.PageSize, 10, 1, 10000)) : Math.max(1, rows.length);
12
+ const pages = Math.max(1, Math.ceil(rows.length / size)), page = Math.floor(bound(options.Page, 1, 1, pages));
13
+ return { rows: rows.slice((page - 1) * size, page * size), page, pages, total: rows.length, start: rows.length ? (page - 1) * size + 1 : 0, end: Math.min(page * size, rows.length) };
14
+ }
15
+
16
+ /** Semantic HTML table, client-side sorting/paging, no grid engine or editing model. */
17
+ export function createTable<Row extends object>(host: HTMLElement, initial: TableOptions<Row>, events: TableEvents<Row> = {}) {
18
+ let p = { ...initial }, disposed = false; const root = el('div', 'blu-table'), scroll = el('div', 'blu-table__scroll');
19
+ const table = el('table'), caption = el('caption'), head = el('thead'), body = el('tbody'), footer = el('div', 'blu-table__footer');
20
+ const status = el('span'), previous = el('button', 'blu-table-button', 'Previous'), next = el('button', 'blu-table-button', 'Next');
21
+ let headerSignature = '';
22
+ caption.id = uid(); scroll.tabIndex = 0; scroll.setAttribute('role', 'region'); scroll.setAttribute('aria-labelledby', caption.id);
23
+ previous.type = next.type = 'button'; status.setAttribute('role', 'status'); footer.append(status, previous, next); table.append(caption, head, body); scroll.append(table); root.append(scroll, footer); host.append(root);
24
+ function update(patch: Partial<TableOptions<Row>>) {
25
+ if (disposed) throw Error('Component is destroyed.'); p = { ...p, ...patch };
26
+ if (!p.Columns.length) throw Error('Table needs at least one column.');
27
+ if (new Set(p.Columns.map(c => c.Key)).size !== p.Columns.length) throw Error('Table column keys must be unique.');
28
+ root.className = 'blu-table' + (p.ExtendedClass ? ' ' + p.ExtendedClass : ''); root.dataset.density = p.Density || 'Comfortable'; root.dataset.striped = String(!!p.Striped);
29
+ table.setAttribute('aria-busy', String(!!p.Loading)); caption.textContent = p.Caption;
30
+ const focused = document.activeElement instanceof HTMLElement && root.contains(document.activeElement) ? document.activeElement.dataset.focusKey : undefined;
31
+ const focusedPagination = document.activeElement === next ? next : document.activeElement === previous ? previous : undefined;
32
+ const nextHeaderSignature = JSON.stringify([p.Columns.map(c => [c.Key, c.Header, !!c.Sortable, c.Align || 'Left']), !!p.Actions?.length]);
33
+ // Retain sortable header controls during sorting, paging and row updates.
34
+ // VoiceOver can activate a header without moving DOM keyboard focus to it.
35
+ if (nextHeaderSignature !== headerSignature) {
36
+ headerSignature = nextHeaderSignature;
37
+ head.replaceChildren(); const tr = el('tr'); head.append(tr);
38
+ for (const column of p.Columns) {
39
+ const th = el('th'); th.scope = 'col'; th.style.textAlign = (column.Align || 'Left').toLowerCase();
40
+ if (column.Sortable) {
41
+ const button = el('button', 'blu-table__sort', column.Header); button.type = 'button'; button.dataset.focusKey = 'sort-' + column.Key;
42
+ const glyph = el('span'); glyph.setAttribute('aria-hidden', 'true'); button.append(glyph);
43
+ button.onclick = () => { const direction = p.SortKey === column.Key && p.SortDirection !== 'Descending' ? 'Descending' : 'Ascending'; update({ SortKey: column.Key, SortDirection: direction, Page: 1 } as Partial<TableOptions<Row>>); events.SortChanged?.(column.Key, direction); };
44
+ th.append(button);
45
+ } else th.textContent = column.Header; tr.append(th);
46
+ }
47
+ if (p.Actions?.length) { const th = el('th', '', 'Actions'); th.scope = 'col'; tr.append(th); }
48
+ }
49
+ p.Columns.forEach((column, index) => {
50
+ if (!column.Sortable) return;
51
+ const th = head.rows[0].cells[index];
52
+ th.setAttribute('aria-sort', p.SortKey === column.Key ? (p.SortDirection || 'Ascending').toLowerCase() : 'none');
53
+ th.querySelector('span')!.textContent = p.SortKey === column.Key ? (p.SortDirection === 'Descending' ? '↓' : '↑') : '↕';
54
+ });
55
+ const state = tableRows(p); p.Page = state.page; body.replaceChildren();
56
+ if (p.Loading || !state.rows.length) {
57
+ const row = el('tr'), cell = el('td', 'blu-table__empty', p.Loading ? 'Loading records…' : (p.EmptyMessage || 'No records yet.')); cell.colSpan = p.Columns.length + (p.Actions?.length ? 1 : 0); row.append(cell); body.append(row);
58
+ } else for (const [index, row] of state.rows.entries()) {
59
+ const rowEl = el('tr'); for (const column of p.Columns) {
60
+ const cell = el('td'); cell.style.textAlign = (column.Align || 'Left').toLowerCase();
61
+ cell.textContent = column.Format ? column.Format(row[column.Key], row) : String(row[column.Key] ?? '—'); rowEl.append(cell);
62
+ }
63
+ if (p.Actions?.length) { const cell = el('td'), actions = el('div', 'blu-table__actions');
64
+ for (const action of p.Actions) {
65
+ const button = el('button', 'blu-table-button', action.Label); button.type = 'button'; button.disabled = typeof action.Disabled === 'function' ? action.Disabled(row) : !!action.Disabled;
66
+ button.dataset.focusKey = 'row-' + String(p.RowKey ? row[p.RowKey] : index) + '-' + action.Key;
67
+ if (p.RowKey) button.setAttribute('aria-label', action.Label + ' ' + String(row[p.RowKey]));
68
+ button.onclick = () => events.RowAction?.(action.Key, row); actions.append(button);
69
+ } cell.append(actions); rowEl.append(cell);
70
+ } body.append(rowEl);
71
+ }
72
+ status.textContent = p.Loading ? 'Loading records…' : `${state.start}–${state.end} of ${state.total} records`;
73
+ previous.hidden = next.hidden = !p.PageSize; previous.disabled = !!p.Loading || state.page <= 1; next.disabled = !!p.Loading || state.page >= state.pages;
74
+ previous.onclick = () => { update({ Page: state.page - 1 } as Partial<TableOptions<Row>>); events.PageChanged?.(p.Page!); };
75
+ next.onclick = () => { update({ Page: state.page + 1 } as Partial<TableOptions<Row>>); events.PageChanged?.(p.Page!); };
76
+ if (focused) Array.from(root.querySelectorAll<HTMLElement>('[data-focus-key]')).find(node => node.dataset.focusKey === focused)?.focus();
77
+ // Safari restarts Tab navigation at the page top if the focused pagination
78
+ // button becomes disabled. Keep the user at the available adjacent control.
79
+ if (!p.Loading && p.PageSize) {
80
+ if (focusedPagination === next && next.disabled) (previous.disabled ? scroll : previous).focus();
81
+ else if (focusedPagination === previous && previous.disabled) (next.disabled ? scroll : next).focus();
82
+ }
83
+ }
84
+ try { update({}); } catch (error) { root.remove(); throw error; }
85
+ return { element: root, update, getState: () => ({ Page: p.Page || 1, SortKey: p.SortKey, SortDirection: p.SortDirection || 'Ascending' }), destroy() { if (disposed) return; disposed = true; root.remove(); } };
86
+ }
@@ -0,0 +1,73 @@
1
+ import { el, attr, frame, lifecycle, common, bound, imageUrl } from './shared.js';
2
+ import type { Adornment, TextInputOptions, TextInputEvents, TextAreaOptions, FieldEvents, FieldComponent } from './types.js';
3
+
4
+ export function createTextInput(host: HTMLElement, initial: TextInputOptions = {}, events: TextInputEvents = {}): FieldComponent<TextInputOptions, string> {
5
+ let p = { ...initial }, revealed = false;
6
+ const f = frame(host, p), shell = el('div', 'blu-text'), input = el('input', 'blu-text__input');
7
+ const leading = el('span', 'blu-text__adornment'), trailing = el('span', 'blu-text__adornment');
8
+ const clear = el('button', 'blu-text__action', '×'), reveal = el('button', 'blu-text__action', 'Show');
9
+ clear.type = reveal.type = 'button'; clear.setAttribute('aria-label', 'Clear ' + (p.Label || 'text'));
10
+ shell.append(leading, input, trailing, clear, reveal); f.body.append(shell);
11
+ const life = lifecycle(f.root, reset);
12
+ const adornmentKinds = new WeakMap<HTMLElement, Adornment['Kind']>();
13
+ function adorn(target: HTMLElement, value?: Adornment) {
14
+ // Keep an existing action alive while validation/state updates run. Replacing
15
+ // the focused button leaves VoiceOver pointing at a detached control.
16
+ if (adornmentKinds.get(target) !== value?.Kind) target.replaceChildren();
17
+ target.removeAttribute('role'); target.removeAttribute('aria-label'); target.removeAttribute('aria-hidden'); target.hidden = !value;
18
+ if (!value) { adornmentKinds.delete(target); return; }
19
+ adornmentKinds.set(target, value.Kind);
20
+ if (value.Kind === 'Image') { const img = target.querySelector('img') || el('img'); const src = imageUrl(value.Src); if (img.src !== src) img.src = src; img.alt = value.Alt; if (!img.parentNode) target.append(img); }
21
+ else if (value.Kind === 'Action') {
22
+ const button = target.querySelector('button') || el('button', 'blu-text__action'); button.type = 'button'; button.textContent = value.Text; button.setAttribute('aria-label', value.Label);
23
+ button.disabled = !!p.Disabled || !!p.ReadOnly; button.onclick = () => events.Action?.(value.Key, input.value); if (!button.parentNode) target.append(button);
24
+ } else { target.textContent = value.Text; if (value.Kind === 'Icon') { attr(target, 'role', value.Label ? 'img' : undefined); attr(target, 'aria-label', value.Label); attr(target, 'aria-hidden', value.Label ? undefined : 'true'); } }
25
+ }
26
+ function refreshActions() { clear.hidden = !p.Clearable || !input.value || !!p.ReadOnly; reveal.hidden = !p.RevealPassword || p.Type !== 'password'; }
27
+ function update(patch: Partial<TextInputOptions>) {
28
+ life.check(); p = { ...p, ...patch }; f.paint(p, [input]); common(input, p);
29
+ input.type = p.Type === 'password' && revealed ? 'text' : (p.Type || 'text');
30
+ if (input.value !== (p.Value || '')) input.value = p.Value || '';
31
+ input.placeholder = p.Placeholder || ''; attr(input, 'maxlength', p.MaxLength === undefined ? undefined : Math.max(0, Math.floor(p.MaxLength)));
32
+ attr(input, 'autocomplete', p.Autocomplete); adorn(leading, p.Leading); adorn(trailing, p.Trailing);
33
+ clear.disabled = reveal.disabled = !!p.Disabled; clear.setAttribute('aria-label', 'Clear ' + (p.Label || 'text'));
34
+ reveal.textContent = revealed ? 'Hide' : 'Show'; reveal.setAttribute('aria-label', revealed ? 'Hide password' : 'Show password');
35
+ reveal.setAttribute('aria-pressed', String(revealed)); refreshActions();
36
+ }
37
+ function changed() { p.Value = input.value; refreshActions(); events.Change?.(input.value); }
38
+ function reset() { revealed = false; update({ Value: initial.Value || '' }); }
39
+ input.addEventListener('input', changed, { signal: life.signal });
40
+ input.addEventListener('blur', () => events.Blur?.(), { signal: life.signal });
41
+ clear.addEventListener('click', () => { input.value = ''; changed(); input.focus(); }, { signal: life.signal });
42
+ reveal.addEventListener('click', () => { revealed = !revealed; update({}); input.focus(); }, { signal: life.signal });
43
+ update({});
44
+ return { element: f.root, control: input, update, reset, getValue: () => input.value, focus: () => input.focus(), validate: () => input.reportValidity(), destroy: life.destroy };
45
+ }
46
+
47
+ export function createTextArea(host: HTMLElement, initial: TextAreaOptions = {}, events: FieldEvents<string> = {}): FieldComponent<TextAreaOptions, string> {
48
+ let p = { ...initial };
49
+ const f = frame(host, p), input = el('textarea', 'blu-textarea'), count = el('div', 'blu-field__count');
50
+ count.id = f.id + '-count'; f.body.append(input, count); const life = lifecycle(f.root, reset);
51
+ function resize() {
52
+ count.hidden = !p.ShowCount; count.textContent = input.value.length + (p.MaxLength === undefined ? ' characters' : ' / ' + p.MaxLength);
53
+ input.style.maxHeight = p.AutoGrow ? bound(p.MaxHeight, 320, 80, 2000) + 'px' : '';
54
+ input.style.resize = p.AutoGrow ? 'none' : 'vertical';
55
+ input.style.height = ''; if (p.AutoGrow) input.style.height = Math.min(input.scrollHeight + 2, bound(p.MaxHeight, 320, 80, 2000)) + 'px';
56
+ }
57
+ function update(patch: Partial<TextAreaOptions>) {
58
+ life.check(); p = { ...p, ...patch }; f.paint(p, [input]); common(input, p);
59
+ input.rows = Math.floor(bound(p.Rows, 4, 1, 30)); input.placeholder = p.Placeholder || '';
60
+ attr(input, 'maxlength', p.MaxLength === undefined ? undefined : Math.max(0, Math.floor(p.MaxLength)));
61
+ if (input.value !== (p.Value || '')) input.value = p.Value || '';
62
+ if (p.ShowCount) attr(input, 'aria-describedby', [input.getAttribute('aria-describedby'), count.id].filter(Boolean).join(' '));
63
+ resize();
64
+ }
65
+ function reset() { update({ Value: initial.Value || '' }); }
66
+ input.addEventListener('input', () => { p.Value = input.value; resize(); events.Change?.(input.value); }, { signal: life.signal });
67
+ input.addEventListener('blur', () => events.Blur?.(), { signal: life.signal });
68
+ let lastWidth = 0;
69
+ // Observe the host width only; height changes from auto-grow must not feed back.
70
+ const widthObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(entries => { const w = entries[0].contentRect.width; if (w !== lastWidth) { lastWidth = w; resize(); } }) : undefined;
71
+ widthObserver?.observe(f.root); update({});
72
+ return { element: f.root, control: input, update, reset, getValue: () => input.value, focus: () => input.focus(), validate: () => input.reportValidity(), destroy() { widthObserver?.disconnect(); life.destroy(); } };
73
+ }
@@ -0,0 +1,59 @@
1
+ /** Local Community additions. Not present in the published 1.0.1 package. */
2
+ export type FieldSize = 'Small' | 'Medium' | 'Large';
3
+ export interface FieldOptions {
4
+ Id?: string; Name?: string; Label?: string; Description?: string; Error?: string;
5
+ Required?: boolean; Disabled?: boolean; ReadOnly?: boolean; Size?: FieldSize;
6
+ ExtendedClass?: string;
7
+ }
8
+ export interface FieldEvents<T> { Change?: (value: T) => void; Blur?: () => void; }
9
+ export interface FieldComponent<P, T> {
10
+ element: HTMLElement; control: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
11
+ update(patch: Partial<P>): void; getValue(): T; reset(): void; focus(): void;
12
+ validate(): boolean; destroy(): void;
13
+ }
14
+ export type Adornment =
15
+ | { Kind: 'Text'; Text: string }
16
+ | { Kind: 'Icon'; Text: string; Label?: string }
17
+ | { Kind: 'Image'; Src: string; Alt: string }
18
+ | { Kind: 'Action'; Text: string; Label: string; Key: string };
19
+ export interface TextInputOptions extends FieldOptions {
20
+ Value?: string; Type?: 'text' | 'email' | 'password' | 'search' | 'tel' | 'url';
21
+ Placeholder?: string; MaxLength?: number; Autocomplete?: string;
22
+ Leading?: Adornment; Trailing?: Adornment; Clearable?: boolean; RevealPassword?: boolean;
23
+ }
24
+ export interface TextInputEvents extends FieldEvents<string> { Action?: (key: string, value: string) => void; }
25
+ export interface TextAreaOptions extends FieldOptions {
26
+ Value?: string; Placeholder?: string; Rows?: number; MaxLength?: number;
27
+ AutoGrow?: boolean; MaxHeight?: number; ShowCount?: boolean;
28
+ }
29
+ export interface CheckboxOptions extends FieldOptions { Checked?: boolean; Indeterminate?: boolean; Value?: string; }
30
+ export interface SwitchOptions extends FieldOptions { Checked?: boolean; OnText?: string; OffText?: string; }
31
+ export interface RadioButtonOptions extends FieldOptions { Checked?: boolean; Value: string; }
32
+ export interface Choice { Value: string; Label: string; Description?: string; Disabled?: boolean; }
33
+ export interface RadioGroupOptions extends FieldOptions { Value?: string; Options: Choice[]; Direction?: 'Horizontal' | 'Vertical'; }
34
+ export interface SelectOptions extends FieldOptions { Value?: string; Options: Choice[]; Placeholder?: string; Clearable?: boolean; Searchable?: boolean; }
35
+ export interface FormFieldOptions extends FieldOptions { Control?: HTMLElement; }
36
+ export interface ColumnsOptions {
37
+ Ratio?: 'Equal' | 'OneThirdTwoThirds' | 'TwoThirdsOneThird' | 'OneQuarterThreeQuarters' | 'ThreeQuartersOneQuarter' | 'WideCenter';
38
+ /** Match column heights within a row and stretch a single content wrapper. Stacked rows keep natural height. */
39
+ EqualHeight?: boolean;
40
+ Gap?: number; Align?: 'Start' | 'Center' | 'End' | 'Stretch';
41
+ StackAt?: 'Small' | 'Medium' | 'Large'; ExtendedClass?: string;
42
+ }
43
+ export interface ColumnsComponent { element: HTMLElement; columns: HTMLElement[]; update(patch: Partial<ColumnsOptions>): void; destroy(): void; }
44
+ export interface TableColumn<Row extends object> {
45
+ Key: Extract<keyof Row, string>; Header: string; Sortable?: boolean; Align?: 'Left' | 'Center' | 'Right';
46
+ Format?: (value: Row[Extract<keyof Row, string>], row: Row) => string;
47
+ }
48
+ export interface TableAction<Row> { Key: string; Label: string; Disabled?: boolean | ((row: Row) => boolean); }
49
+ export interface TableOptions<Row extends object> {
50
+ Columns: TableColumn<Row>[]; Rows: Row[]; Caption: string; RowKey?: Extract<keyof Row, string>;
51
+ Actions?: TableAction<Row>[]; Loading?: boolean; EmptyMessage?: string;
52
+ PageSize?: number; Page?: number; SortKey?: Extract<keyof Row, string>; SortDirection?: 'Ascending' | 'Descending';
53
+ Density?: 'Compact' | 'Comfortable'; Striped?: boolean; ExtendedClass?: string;
54
+ }
55
+ export interface TableEvents<Row> {
56
+ RowAction?: (key: string, row: Row) => void;
57
+ SortChanged?: (key: Extract<keyof Row, string>, direction: 'Ascending' | 'Descending') => void;
58
+ PageChanged?: (page: number) => void;
59
+ }
@@ -0,0 +1,12 @@
1
+ export * from './checkbox.js';
2
+ export * from './text-input.js';
3
+ export * from './text-area.js';
4
+ export * from './radio-button.js';
5
+ export * from './radio-group.js';
6
+ export * from './switch.js';
7
+ export * from './select.js';
8
+ export * from './form-field.js';
9
+ export * from './columns2.js';
10
+ export * from './columns3.js';
11
+ export * from './columns4.js';
12
+ export * from './table.js';
package/src/index.ts CHANGED
@@ -54,3 +54,5 @@ export * from './font-provider.js';
54
54
  export * from './shape-mask.js';
55
55
  export * from './chip-group.js';
56
56
  export * from './grid-basic.js';
57
+
58
+ export * from './form-layout.js';
package/src/inputs.ts CHANGED
@@ -6,3 +6,11 @@ export * from './date-picker.js';
6
6
  export * from './time-picker.js';
7
7
  export * from './date-time-picker.js';
8
8
  export * from './date-range-picker.js';
9
+ export * from './checkbox.js';
10
+ export * from './text-input.js';
11
+ export * from './text-area.js';
12
+ export * from './radio-button.js';
13
+ export * from './radio-group.js';
14
+ export * from './switch.js';
15
+ export * from './select.js';
16
+ export * from './form-field.js';
package/src/layout.ts CHANGED
@@ -6,3 +6,6 @@ export * from './splitter.js';
6
6
  export * from './app-layout.js';
7
7
  export * from './page-layout.js';
8
8
  export * from './shape-mask.js';
9
+ export * from './columns2.js';
10
+ export * from './columns3.js';
11
+ export * from './columns4.js';
@@ -0,0 +1,38 @@
1
+ import type { MotionController } from './controller.js';
2
+
3
+ export interface ActionMessages { pending: string; success: string; error: string }
4
+ export type ActionResult = 'success' | 'error' | 'busy' | 'cancelled';
5
+
6
+ /** Feedback follows an actual action promise. No simulated success, timed reset,
7
+ * duplicated live region or overlapping action. The caller owns data and controls. */
8
+ export function createActionFeedback(status: HTMLElement, motion: MotionController) {
9
+ const original = { role: status.getAttribute('role'), atomic: status.getAttribute('aria-atomic'), state: status.getAttribute('data-action-state') };
10
+ status.setAttribute('role', 'status'); status.setAttribute('aria-atomic', 'true');
11
+ let dead = false, pending = false;
12
+ const show = (text: string, state: string) => {
13
+ status.textContent = text; status.dataset.actionState = state;
14
+ void motion.play(status, 'fade', { duration: 350, easing: 'ease-out' });
15
+ };
16
+ return {
17
+ get pending() { return pending; },
18
+ async run(action: () => unknown | Promise<unknown>, messages: ActionMessages): Promise<ActionResult> {
19
+ if (dead) return 'cancelled';
20
+ if (pending) return 'busy';
21
+ pending = true; show(messages.pending, 'pending');
22
+ try {
23
+ await action();
24
+ if (dead) return 'cancelled';
25
+ show(messages.success, 'success'); return 'success';
26
+ } catch {
27
+ if (dead) return 'cancelled';
28
+ show(messages.error, 'error'); return 'error';
29
+ } finally { pending = false; }
30
+ },
31
+ destroy() {
32
+ if (dead) return; dead = true; motion.cancel(status);
33
+ for (const [key, value] of [['role', original.role], ['aria-atomic', original.atomic], ['data-action-state', original.state]]) {
34
+ if (value === null) status.removeAttribute(key!); else status.setAttribute(key!, value!);
35
+ }
36
+ },
37
+ };
38
+ }