@kenlo/sds-design-system 0.9.9

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 (246) hide show
  1. package/README.md +129 -0
  2. package/components/Accordion/Accordion.css +186 -0
  3. package/components/Accordion/accordion-variants.js +17 -0
  4. package/components/Accordion/accordion.marko +264 -0
  5. package/components/AccordionGroup/AccordionGroup.css +35 -0
  6. package/components/AccordionGroup/accordion-group.marko +17 -0
  7. package/components/Alert/Alert.css +104 -0
  8. package/components/Alert/alert-variants.js +35 -0
  9. package/components/Alert/alert.marko +94 -0
  10. package/components/AttachmentList/AttachmentList.css +92 -0
  11. package/components/AttachmentList/attachment-list.marko +51 -0
  12. package/components/AttachmentList/attachment-row.marko +167 -0
  13. package/components/Avatar/Avatar.css +110 -0
  14. package/components/Avatar/avatar-variants.js +12 -0
  15. package/components/Avatar/avatar.marko +71 -0
  16. package/components/Badge/Badge.css +381 -0
  17. package/components/Badge/badge-dismissible.marko +143 -0
  18. package/components/Badge/badge-variants.js +35 -0
  19. package/components/Badge/badge.marko +134 -0
  20. package/components/BottomSheet/BottomSheet.css +300 -0
  21. package/components/BottomSheet/bottom-sheet.marko +340 -0
  22. package/components/BulkActionBar/BulkActionBar.css +88 -0
  23. package/components/BulkActionBar/bulk-action-bar.marko +186 -0
  24. package/components/Button/Button.css +340 -0
  25. package/components/Button/button-variants.js +25 -0
  26. package/components/Button/button.marko +139 -0
  27. package/components/Card/Card.css +391 -0
  28. package/components/Card/card-body.marko +5 -0
  29. package/components/Card/card-collapsible.marko +94 -0
  30. package/components/Card/card-footer.marko +12 -0
  31. package/components/Card/card-header.marko +30 -0
  32. package/components/Card/card-info-pair.marko +17 -0
  33. package/components/Card/card-info-row.marko +12 -0
  34. package/components/Card/card-section.marko +14 -0
  35. package/components/Card/card-variants.js +24 -0
  36. package/components/Card/card.marko +124 -0
  37. package/components/Checkbox/Checkbox.css +249 -0
  38. package/components/Checkbox/checkbox-variants.js +21 -0
  39. package/components/Checkbox/checkbox.marko +128 -0
  40. package/components/ColorPicker/ColorPicker.css +253 -0
  41. package/components/ColorPicker/color-maths.js +104 -0
  42. package/components/ColorPicker/color-picker.marko +474 -0
  43. package/components/DatePicker/DatePicker.css +429 -0
  44. package/components/DatePicker/date-logic.js +225 -0
  45. package/components/DatePicker/date-picker.marko +492 -0
  46. package/components/Drawer/Drawer.css +174 -0
  47. package/components/Drawer/drawer.marko +262 -0
  48. package/components/Floating/Floating.css +28 -0
  49. package/components/Floating/floating-position.js +251 -0
  50. package/components/Floating/floating.marko +291 -0
  51. package/components/Form/Form.css +15 -0
  52. package/components/Form/form-context.js +69 -0
  53. package/components/Form/form.marko +73 -0
  54. package/components/GradientPicker/GradientPicker.css +179 -0
  55. package/components/GradientPicker/gradient-picker.marko +398 -0
  56. package/components/Grid/Grid.css +161 -0
  57. package/components/Grid/col.marko +36 -0
  58. package/components/Grid/grid-variants.js +43 -0
  59. package/components/Grid/grid.marko +25 -0
  60. package/components/Header/Header.css +457 -0
  61. package/components/Header/header.marko +523 -0
  62. package/components/Icon/Icon.css +44 -0
  63. package/components/Icon/icon-colors.js +31 -0
  64. package/components/Icon/icon.marko +56 -0
  65. package/components/IconButton/IconButton.css +85 -0
  66. package/components/IconButton/icon-button-variants.js +18 -0
  67. package/components/IconButton/icon-button.marko +103 -0
  68. package/components/Incrementer/Incrementer.css +94 -0
  69. package/components/Incrementer/incrementer.marko +70 -0
  70. package/components/Input/Input.css +547 -0
  71. package/components/Input/input-countries.js +109 -0
  72. package/components/Input/input-masks.js +218 -0
  73. package/components/Input/input-types.js +194 -0
  74. package/components/Input/input.marko +530 -0
  75. package/components/Menu/Menu.css +651 -0
  76. package/components/Menu/menu-variants.js +89 -0
  77. package/components/Menu/menu.marko +560 -0
  78. package/components/Modal/Modal.css +160 -0
  79. package/components/Modal/modal.marko +296 -0
  80. package/components/Nav/Nav.css +298 -0
  81. package/components/Nav/nav-variants.js +23 -0
  82. package/components/Nav/nav.marko +164 -0
  83. package/components/Notification/Notification.css +78 -0
  84. package/components/Notification/notification-variants.js +30 -0
  85. package/components/Notification/notification.marko +55 -0
  86. package/components/Pagination/Pagination.css +78 -0
  87. package/components/Pagination/pagination-pages.js +66 -0
  88. package/components/Pagination/pagination.marko +141 -0
  89. package/components/Pill/Pill.css +193 -0
  90. package/components/Pill/pill-variants.js +16 -0
  91. package/components/Pill/pill.marko +165 -0
  92. package/components/Popover/Popover.css +121 -0
  93. package/components/Popover/popover.marko +96 -0
  94. package/components/Radio/Radio.css +230 -0
  95. package/components/Radio/radio-variants.js +16 -0
  96. package/components/Radio/radio.marko +93 -0
  97. package/components/RadioGroup/RadioGroup.css +32 -0
  98. package/components/RadioGroup/radio-group.marko +93 -0
  99. package/components/SearchBar/SearchBar.css +181 -0
  100. package/components/SearchBar/search-bar-variants.js +24 -0
  101. package/components/SearchBar/search-bar.marko +157 -0
  102. package/components/Select/Select.css +427 -0
  103. package/components/Select/select-options.js +163 -0
  104. package/components/Select/select.marko +449 -0
  105. package/components/Separator/Separator.css +22 -0
  106. package/components/Separator/separator.marko +14 -0
  107. package/components/Skeleton/Skeleton.css +100 -0
  108. package/components/Skeleton/skeleton-avatar.marko +28 -0
  109. package/components/Skeleton/skeleton-button.marko +22 -0
  110. package/components/Skeleton/skeleton-image.marko +20 -0
  111. package/components/Skeleton/skeleton-input.marko +22 -0
  112. package/components/Skeleton/skeleton-variants.js +16 -0
  113. package/components/Skeleton/skeleton.marko +53 -0
  114. package/components/Spinner/Spinner.css +173 -0
  115. package/components/Spinner/spinner-geometry.js +39 -0
  116. package/components/Spinner/spinner-overlay.marko +97 -0
  117. package/components/Spinner/spinner.marko +88 -0
  118. package/components/StatCard/StatCard.css +90 -0
  119. package/components/StatCard/stat-card-group.marko +5 -0
  120. package/components/StatCard/stat-card.marko +57 -0
  121. package/components/Switch/Switch.css +209 -0
  122. package/components/Switch/switch.marko +99 -0
  123. package/components/Table/Table.css +270 -0
  124. package/components/Table/table-cell.marko +28 -0
  125. package/components/Table/table-header.marko +27 -0
  126. package/components/Table/table-logic.js +129 -0
  127. package/components/Table/table.marko +372 -0
  128. package/components/Text/Text.css +79 -0
  129. package/components/Text/text-variants.js +25 -0
  130. package/components/Text/text.marko +48 -0
  131. package/components/TextEditor/TextEditor.css +78 -0
  132. package/components/TextEditor/safe-url.js +50 -0
  133. package/components/TextEditor/text-editor-commands.js +22 -0
  134. package/components/TextEditor/text-editor.marko +253 -0
  135. package/components/Toast/Toast.css +88 -0
  136. package/components/Toast/toast-api.js +19 -0
  137. package/components/Toast/toast-store.js +118 -0
  138. package/components/Toast/toast-variants.js +16 -0
  139. package/components/Toast/toast.marko +93 -0
  140. package/components/Toast/toaster.marko +70 -0
  141. package/components/Toggle/Toggle.css +169 -0
  142. package/components/Toggle/toggle.marko +93 -0
  143. package/components/Tooltip/Tooltip.css +115 -0
  144. package/components/Tooltip/tooltip.marko +77 -0
  145. package/components/Uploader/Uploader.css +180 -0
  146. package/components/Uploader/crop-geometry.js +161 -0
  147. package/components/Uploader/uploader.marko +269 -0
  148. package/foundations/Scrollbar/Scrollbar.css +26 -0
  149. package/foundations/attrs.js +27 -0
  150. package/foundations/breakpoints.js +27 -0
  151. package/foundations/contrast.js +74 -0
  152. package/foundations/custom-scheme.js +222 -0
  153. package/foundations/overlay.js +108 -0
  154. package/foundations/styles.css +284 -0
  155. package/foundations/text.js +22 -0
  156. package/foundations/theme-server.js +81 -0
  157. package/foundations/theme.js +42 -0
  158. package/foundations/tokens.css +1656 -0
  159. package/icons/custom/custom-video-horizontal-filled.marko +6 -0
  160. package/icons/custom/custom-video-horizontal-regular.marko +6 -0
  161. package/icons/custom/custom-video-tour-3d-filled.marko +7 -0
  162. package/icons/custom/custom-video-tour-3d-regular.marko +9 -0
  163. package/icons/custom/custom-video-vertical-filled.marko +6 -0
  164. package/icons/custom/custom-video-vertical-regular.marko +6 -0
  165. package/icons/generated/fluent-add-regular.marko +3 -0
  166. package/icons/generated/fluent-alert-regular.marko +3 -0
  167. package/icons/generated/fluent-apps-regular.marko +3 -0
  168. package/icons/generated/fluent-arrow-download-regular.marko +3 -0
  169. package/icons/generated/fluent-arrow-left-regular.marko +3 -0
  170. package/icons/generated/fluent-arrow-right-regular.marko +3 -0
  171. package/icons/generated/fluent-arrow-rotate-clockwise-regular.marko +3 -0
  172. package/icons/generated/fluent-arrow-rotate-counterclockwise-regular.marko +3 -0
  173. package/icons/generated/fluent-border-all-regular.marko +3 -0
  174. package/icons/generated/fluent-calendar-regular.marko +3 -0
  175. package/icons/generated/fluent-checkmark-circle-filled.marko +3 -0
  176. package/icons/generated/fluent-checkmark-regular.marko +3 -0
  177. package/icons/generated/fluent-chevron-down-regular.marko +3 -0
  178. package/icons/generated/fluent-chevron-left-regular.marko +3 -0
  179. package/icons/generated/fluent-chevron-right-regular.marko +3 -0
  180. package/icons/generated/fluent-chevron-up-regular.marko +3 -0
  181. package/icons/generated/fluent-circle-half-fill-regular.marko +3 -0
  182. package/icons/generated/fluent-clock-regular.marko +3 -0
  183. package/icons/generated/fluent-color-regular.marko +3 -0
  184. package/icons/generated/fluent-crop-regular.marko +3 -0
  185. package/icons/generated/fluent-dark-theme-regular.marko +3 -0
  186. package/icons/generated/fluent-delete-regular.marko +3 -0
  187. package/icons/generated/fluent-desktop-regular.marko +3 -0
  188. package/icons/generated/fluent-dismiss-circle-filled.marko +3 -0
  189. package/icons/generated/fluent-dismiss-regular.marko +3 -0
  190. package/icons/generated/fluent-document-pdf-regular.marko +3 -0
  191. package/icons/generated/fluent-document-regular.marko +3 -0
  192. package/icons/generated/fluent-eye-off-regular.marko +3 -0
  193. package/icons/generated/fluent-eye-regular.marko +3 -0
  194. package/icons/generated/fluent-eyedropper-regular.marko +3 -0
  195. package/icons/generated/fluent-grid-regular.marko +3 -0
  196. package/icons/generated/fluent-history-regular.marko +3 -0
  197. package/icons/generated/fluent-home-regular.marko +3 -0
  198. package/icons/generated/fluent-image-regular.marko +3 -0
  199. package/icons/generated/fluent-info-regular.marko +3 -0
  200. package/icons/generated/fluent-link-regular.marko +3 -0
  201. package/icons/generated/fluent-more-horizontal-regular.marko +3 -0
  202. package/icons/generated/fluent-navigation-regular.marko +3 -0
  203. package/icons/generated/fluent-paint-brush-regular.marko +3 -0
  204. package/icons/generated/fluent-question-circle-regular.marko +3 -0
  205. package/icons/generated/fluent-search-regular.marko +3 -0
  206. package/icons/generated/fluent-settings-regular.marko +3 -0
  207. package/icons/generated/fluent-square-hint-regular.marko +3 -0
  208. package/icons/generated/fluent-subtract-regular.marko +3 -0
  209. package/icons/generated/fluent-text-align-center-regular.marko +3 -0
  210. package/icons/generated/fluent-text-align-left-regular.marko +3 -0
  211. package/icons/generated/fluent-text-align-right-regular.marko +3 -0
  212. package/icons/generated/fluent-text-bold-regular.marko +3 -0
  213. package/icons/generated/fluent-text-bullet-list-regular.marko +3 -0
  214. package/icons/generated/fluent-text-font-regular.marko +3 -0
  215. package/icons/generated/fluent-text-font-size-regular.marko +3 -0
  216. package/icons/generated/fluent-text-italic-regular.marko +3 -0
  217. package/icons/generated/fluent-text-number-list-ltr-regular.marko +3 -0
  218. package/icons/generated/fluent-video-regular.marko +3 -0
  219. package/icons/generated/fluent-warning-filled.marko +3 -0
  220. package/icons/generated/fluent-weather-moon-regular.marko +3 -0
  221. package/icons/generated/fluent-weather-sunny-regular.marko +3 -0
  222. package/icons/generated/fluent-zoom-in-regular.marko +3 -0
  223. package/icons/generated/fluent-zoom-out-regular.marko +3 -0
  224. package/icons/manifest.json +61 -0
  225. package/icons/social/social-facebook-color.marko +7 -0
  226. package/icons/social/social-facebook.marko +6 -0
  227. package/icons/social/social-google-color.marko +20 -0
  228. package/icons/social/social-google.marko +6 -0
  229. package/icons/social/social-instagram-color.marko +16 -0
  230. package/icons/social/social-instagram.marko +6 -0
  231. package/icons/social/social-linkedin-color.marko +8 -0
  232. package/icons/social/social-linkedin.marko +8 -0
  233. package/icons/social/social-messenger-color.marko +15 -0
  234. package/icons/social/social-messenger.marko +6 -0
  235. package/icons/social/social-pinterest-color.marko +6 -0
  236. package/icons/social/social-pinterest.marko +6 -0
  237. package/icons/social/social-telegram-color.marko +7 -0
  238. package/icons/social/social-telegram.marko +6 -0
  239. package/icons/social/social-whatsapp-color.marko +8 -0
  240. package/icons/social/social-whatsapp.marko +7 -0
  241. package/icons/social/social-x-color.marko +7 -0
  242. package/icons/social/social-x.marko +6 -0
  243. package/icons/social/social-youtube-color.marko +7 -0
  244. package/icons/social/social-youtube.marko +6 -0
  245. package/marko.json +203 -0
  246. package/package.json +55 -0
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The Menu vocabulary.
3
+ *
4
+ * Ported from kdls-design-system v2.5.1 (0f3edf8) components/Menu/Menu.tsx.
5
+ */
6
+ export const MENU_PLACEMENTS = ['bottom', 'bottomLeft', 'bottomRight'];
7
+ export const MENU_INTENTS = ['primary', 'neutral', 'error', 'success', 'alert'];
8
+
9
+ /**
10
+ * Trigger shapes.
11
+ *
12
+ * KDLS spells these as `variant` and offers navButton | pill | avatar | iconButton. All
13
+ * four are here. `custom` is the fifth and is SDS's own: a caller that needs a shape this
14
+ * list does not have supplies the trigger itself, rather than waiting for the list to
15
+ * grow.
16
+ */
17
+ export const MENU_TRIGGERS = ['navButton', 'pill', 'avatar', 'iconButton', 'custom'];
18
+
19
+ function assertOneOf(label, value, allowed) {
20
+ if (!allowed.includes(value)) {
21
+ throw new Error(`Unknown menu ${label}: "${value}". Allowed: ${allowed.join(', ')}`);
22
+ }
23
+ return value;
24
+ }
25
+
26
+ export const assertPlacement = (v) => assertOneOf('placement', v, MENU_PLACEMENTS);
27
+ export const assertTrigger = (v) => assertOneOf('trigger', v, MENU_TRIGGERS);
28
+ export const assertIntent = (v) => (v === undefined ? v : assertOneOf('intent', v, MENU_INTENTS));
29
+
30
+ /**
31
+ * Normalises whatever the caller passed into one list of rows.
32
+ *
33
+ * Two APIs, both of which KDLS had: an `items` array for a menu built from data, and
34
+ * attribute tags for one written out. They produce the same shape so the template only
35
+ * knows about one.
36
+ */
37
+ export function normaliseItems({ items, item }) {
38
+ const tags = item === undefined ? [] : [].concat(item);
39
+ const defs = items === undefined ? [] : items;
40
+
41
+ const fromTags = tags.map((tag, index) => ({
42
+ key: tag.key ?? `tag-${index}`,
43
+ // `custom` is a row that is not a choice: markup living in the panel, the way KDLS
44
+ // drops a ColorPicker into a Menu.Group. It is not focusable, does not close the
45
+ // menu, and the keyboard steps over it.
46
+ type: tag.separator ? 'divider' : (tag.section ? 'section' : (tag.custom ? 'custom' : undefined)),
47
+ label: tag.label,
48
+ renderBody: tag.renderBody,
49
+ icon: tag.icon,
50
+ intent: tag.intent,
51
+ badge: tag.badge,
52
+ href: tag.href,
53
+ targetBlank: tag.targetBlank,
54
+ active: tag.active,
55
+ disabled: tag.disabled,
56
+ onClick: tag.onClick,
57
+ children: tag.children,
58
+ }));
59
+
60
+ return [...defs, ...fromTags];
61
+ }
62
+
63
+ /**
64
+ * A row and, when it is expanded, the rows inside it — as one flat list.
65
+ *
66
+ * The keyboard walks one list. An expandable row that holds three children is four
67
+ * landing places when it is open and one when it is shut, and the arrow keys have to
68
+ * agree with what is on screen: stepping into a collapsed submenu moves the highlight to
69
+ * something nobody can see.
70
+ *
71
+ * `depth` is what the renderer indents by; `parent` is the index of the row that owns it.
72
+ */
73
+ export function flattenRows(rows, expanded = {}) {
74
+ const out = [];
75
+ rows.forEach((row, index) => {
76
+ const key = row.key ?? index;
77
+ const hasChildren = Array.isArray(row.children) && row.children.length > 0;
78
+ out.push({ ...row, key, depth: 0, expandable: hasChildren, expanded: Boolean(expanded[key]) });
79
+ if (!hasChildren || !expanded[key]) return;
80
+ row.children.forEach((child, childIndex) => {
81
+ out.push({ ...child, key: `${key}-${childIndex}`, depth: 1, parent: key });
82
+ });
83
+ });
84
+ return out;
85
+ }
86
+
87
+ /** Rows a keyboard can land on — a divider or a section header is not one of them. */
88
+ export const isSelectable = (row) =>
89
+ row.type !== 'divider' && row.type !== 'section' && row.type !== 'custom' && !row.disabled;
@@ -0,0 +1,560 @@
1
+ <!-- sds-menu — ported from kdls-design-system v2.5.1 (0f3edf8) components/Menu/Menu.tsx.
2
+
3
+ A trigger and a floating list. Two ways to fill it, both of which KDLS had:
4
+
5
+ <sds-menu label="Ações">
6
+ <@item onClick=onEdit>Editar</@item>
7
+ <@item separator/>
8
+ <@item intent="error" onClick=onDelete>Excluir</@item>
9
+ </sds-menu>
10
+
11
+ <sds-menu label="Ações" items=rows/>
12
+
13
+ KDLS gave its items a React context carrying `close` and `listboxId`, and had them read
14
+ Nav's context as well. Marko 5 has no context, so the menu owns the whole list and renders
15
+ each row itself — see `.claude/component-rules.md` §2c.
16
+
17
+ Not ported in this pass, and each for a stated reason:
18
+
19
+ name, code, agency the profile header, which belongs to the avatar trigger below
20
+ accordion a row that expands inside the panel instead of flying out; it is
21
+ the submenu case in another shape and waits on the same decision
22
+ onChange belongs to the searchable variant, which is not here either
23
+
24
+ submenus a nested floating list with its own focus management; it belongs with
25
+ Popover, which is not ported either
26
+ search needs Input inside the panel, and a filtering decision that is not KDLS's
27
+ to make for us
28
+ pill, avatar trigger shapes that render an sds-pill and an sds-avatar
29
+ tooltip still open on touch behaviour
30
+
31
+ Placement is CSS rather than measured positioning. KDLS computes the panel's position in
32
+ JavaScript; on a page that renders on the server, a menu that only knows where to sit
33
+ after hydration is a menu that jumps. The three placements KDLS offers are all below the
34
+ trigger, so CSS does them — and when Popover lands with real collision detection, this
35
+ grows into it. -->
36
+
37
+ import { assertPlacement, assertTrigger, assertIntent, normaliseItems, flattenRows, isSelectable }
38
+ from "./menu-variants.js";
39
+
40
+ import chevronIcon from "../../icons/generated/fluent-chevron-down-regular.marko";
41
+ // The fallback glyph for an icon trigger with no icon of its own: "more", which is what an
42
+ // unlabelled menu on a row means everywhere it appears.
43
+ import moreIcon from "../../icons/generated/fluent-more-horizontal-regular.marko";
44
+
45
+ class {
46
+ onCreate() {
47
+ this.state = { open: false, activeIndex: -1, expanded: {} };
48
+ }
49
+
50
+ onMount() {
51
+ // Closing on an outside click is what makes a menu feel like a menu. The listener is
52
+ // on the document because the click that should close it happens outside this tree.
53
+ this.onDocumentClick = (event) => {
54
+ if (!this.isOpen) return;
55
+
56
+ // `this.el` rather than `getEl("root")`, and no optional chaining.
57
+ //
58
+ // `getEl("root")?.contains(…)` reads as safe and is the opposite: when the lookup
59
+ // returns nothing the whole expression is undefined, which is falsy, so the guard
60
+ // falls through and the menu closes itself on the very click that opened it. That
61
+ // is exactly what happened with the pill trigger on the portal's own bar — it
62
+ // opened and shut in one click, and a click that did not reach the document opened
63
+ // it fine, which is what pointed here.
64
+ //
65
+ // A menu that cannot find its own element has no business deciding the click was
66
+ // outside it, so an absent root now means "do nothing" rather than "close".
67
+ const root = this.el ?? this.getEl("root");
68
+ if (!root || root.contains(event.target)) return;
69
+
70
+ this.close();
71
+ };
72
+ document.addEventListener("click", this.onDocumentClick);
73
+ }
74
+
75
+ onDestroy() {
76
+ document.removeEventListener("click", this.onDocumentClick);
77
+ }
78
+
79
+ /**
80
+ * The rows the panel shows, with the children of any expanded row folded in.
81
+ *
82
+ * The keyboard walks one list, so an expandable row has to contribute its children to
83
+ * that list exactly when they are on screen — otherwise the arrows step into a submenu
84
+ * nobody has opened.
85
+ */
86
+ get rows() {
87
+ return flattenRows(normaliseItems(this.input), this.state.expanded);
88
+ }
89
+
90
+ /**
91
+ * Open or close a row that holds others.
92
+ *
93
+ * Inside the panel rather than beside it, which is the `accordion` form KDLS offers and
94
+ * the only one ported. Its default is a floating fly-out that opens to the right, and
95
+ * on a phone there is no right to open into — KDLS knows this and forces the accordion
96
+ * inside a vertical Nav. A design system for public products only needs the one that
97
+ * works in both places.
98
+ */
99
+ toggleRow(key) {
100
+ const expanded = { ...this.state.expanded };
101
+ if (expanded[key]) delete expanded[key];
102
+ else expanded[key] = true;
103
+ this.state.expanded = expanded;
104
+ }
105
+
106
+ get isOpen() {
107
+ return this.input.open !== undefined ? this.input.open : this.state.open;
108
+ }
109
+
110
+ /** Reports first, then moves its own state — a controlled caller may refuse. */
111
+ setOpen(next) {
112
+ this.input.onOpenChange?.(next);
113
+ if (this.input.open === undefined) this.state.open = next;
114
+ if (next) this.wantsFocus = true;
115
+ }
116
+
117
+ /**
118
+ * An opened menu puts the caret where the next keystroke belongs.
119
+ *
120
+ * With a header holding a search box, that is the box: the reason to open a list of
121
+ * sixty is to narrow it, and making someone click the field first is asking twice. With
122
+ * no search box it is the first row, so the arrow keys work without a click at all.
123
+ *
124
+ * It runs on update rather than on open: the panel does not exist yet at the moment the
125
+ * state changes, so there is nothing to focus.
126
+ */
127
+ onUpdate() {
128
+ // A filtered list is a different list. The highlight was an index into the old one, so
129
+ // after typing it pointed at whatever happened to be in that position — arrow-down
130
+ // from the search box landed on a row nobody had looked for.
131
+ const size = this.rows.length;
132
+ if (size !== this.lastSize) {
133
+ this.lastSize = size;
134
+ this.state.activeIndex = this.rows.findIndex((row) => isSelectable(row));
135
+ }
136
+
137
+ if (!this.wantsFocus || !this.isOpen) return;
138
+ this.wantsFocus = false;
139
+
140
+ const panel = this.getEl("panel");
141
+ if (!panel) return;
142
+
143
+ const field = panel.querySelector("input, textarea, [contenteditable='true']");
144
+ if (field) {
145
+ field.focus();
146
+ return;
147
+ }
148
+
149
+ this.state.activeIndex = this.rows.findIndex((row) => isSelectable(row));
150
+ panel.querySelector("[role='listbox']")?.focus();
151
+ }
152
+
153
+ toggle() {
154
+ this.setOpen(!this.isOpen);
155
+ this.state.activeIndex = -1;
156
+ }
157
+
158
+ close() {
159
+ this.setOpen(false);
160
+ this.state.activeIndex = -1;
161
+ }
162
+
163
+ /** Moves the highlight, skipping dividers, section headers and disabled rows. */
164
+ move(step) {
165
+ const rows = this.rows;
166
+ const selectable = rows
167
+ .map((row, index) => (isSelectable(row) ? index : -1))
168
+ .filter((index) => index !== -1);
169
+ if (!selectable.length) return;
170
+
171
+ const current = selectable.indexOf(this.state.activeIndex);
172
+ const next = current === -1
173
+ ? (step > 0 ? 0 : selectable.length - 1)
174
+ : (current + step + selectable.length) % selectable.length;
175
+
176
+ this.state.activeIndex = selectable[next];
177
+ }
178
+
179
+ handleTriggerKey(event) {
180
+ if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
181
+ event.preventDefault();
182
+ if (!this.isOpen) this.setOpen(true);
183
+ this.move(1);
184
+ return;
185
+ }
186
+ if (event.key === "Escape") this.close();
187
+ }
188
+
189
+ handleListKey(event) {
190
+ // The header can hold a text field, and the keys mean different things inside it:
191
+ // Space is a space, not a choice, and an arrow should hand the caret to the list
192
+ // rather than move a highlight nobody is looking at.
193
+ const typing = event.target.matches?.("input, textarea, [contenteditable='true']");
194
+
195
+ if (event.key === "ArrowDown") {
196
+ event.preventDefault();
197
+ this.move(1);
198
+ if (typing) this.getEl("panel")?.querySelector("[role='listbox']")?.focus();
199
+ return;
200
+ }
201
+ if (event.key === "ArrowUp") {
202
+ event.preventDefault();
203
+ this.move(-1);
204
+ if (typing) this.getEl("panel")?.querySelector("[role='listbox']")?.focus();
205
+ return;
206
+ }
207
+ if (event.key === "Escape") {
208
+ event.preventDefault();
209
+ this.close();
210
+ this.getEl("trigger")?.focus();
211
+ return;
212
+ }
213
+ if (event.key === "Enter" || (event.key === " " && !typing)) {
214
+ const row = this.rows[this.state.activeIndex];
215
+ if (row) {
216
+ event.preventDefault();
217
+ this.pick(row, event);
218
+ }
219
+ }
220
+ }
221
+
222
+ pick(row, event) {
223
+ if (row.disabled) {
224
+ event.preventDefault();
225
+ return;
226
+ }
227
+ row.onClick?.(event);
228
+ // A menu that stays open after a choice is a menu nobody trusts they clicked.
229
+ this.close();
230
+ }
231
+ }
232
+
233
+ $ {
234
+ const {
235
+ label,
236
+ icon,
237
+ trigger = "navButton",
238
+ placement = "bottom",
239
+ triggerActive = false,
240
+ emptyMessage = "Nenhuma opção",
241
+ open: openInput,
242
+ onOpenChange,
243
+ zIndex,
244
+ class: extraClass = "",
245
+ } = input;
246
+
247
+ assertPlacement(placement);
248
+ assertTrigger(input.triggerBody ? "custom" : trigger);
249
+
250
+ const renderable = (value) => (value && value.renderBody) || value;
251
+
252
+ const rows = component.rows;
253
+ for (const row of rows) assertIntent(row.intent);
254
+
255
+ const ariaLabel = input["aria-label"] ?? input.ariaLabel ?? label ?? null;
256
+ if (!ariaLabel) {
257
+ throw new Error("sds-menu needs a `label`, or an `aria-label` when the trigger shows no text.");
258
+ }
259
+
260
+ // `component.id`, not a random string: Marko's is the same on the server and in the
261
+ // browser, and a random one drawn in the render block is neither.
262
+ const listboxId = `sds-menu-${component.id}`;
263
+ // A visible label and an aria-label together mean the label is a value and the aria-label
264
+ // is its name — the trigger has to say both.
265
+ const namesItself = Boolean(label) && Boolean(input["aria-label"] ?? input.ariaLabel);
266
+ // Controlled when the caller passes `open`, as in Floating and Popover: a menu holding
267
+ // something that opens a fullscreen panel of its own has to be closable by whatever
268
+ // opened that panel.
269
+ const open = openInput !== undefined ? openInput : state.open;
270
+
271
+ const rootClass = [
272
+ "sds-menu",
273
+ `sds-menu--${placement}`,
274
+ open ? "sds-menu--open" : "",
275
+ extraClass,
276
+ ].filter(Boolean).join(" ");
277
+
278
+ const triggerClass = [
279
+ "sds-menu__btn",
280
+ `sds-menu__btn--${trigger}`,
281
+ open ? "sds-menu__btn--open" : "",
282
+ triggerActive ? "sds-menu__btn--active" : "",
283
+ ].filter(Boolean).join(" ");
284
+
285
+ const rowClass = (row) => [
286
+ "sds-menu__item",
287
+ row.active ? "sds-menu__item--active" : "",
288
+ row.disabled ? "sds-menu__item--disabled" : "",
289
+ row.intent ? `sds-menu__item--${row.intent}` : "",
290
+ ].filter(Boolean).join(" ");
291
+ }
292
+
293
+ <div key="root" class=rootClass>
294
+ <if(input.triggerBody)>
295
+ <span
296
+ key="trigger"
297
+ class="sds-menu__trigger-custom"
298
+ role="button"
299
+ tabindex="0"
300
+ aria-expanded=(open ? "true" : "false")
301
+ aria-haspopup="listbox"
302
+ aria-controls=(open ? listboxId : null)
303
+ aria-label=ariaLabel
304
+ on-click("toggle")
305
+ on-keydown("handleTriggerKey")
306
+ >
307
+ <${renderable(input.triggerBody)}/>
308
+ </span>
309
+ </if>
310
+ <else-if(trigger === "pill")>
311
+ <!-- A chip that opens a list. KDLS calls this `variant="pill"` and builds its own
312
+ subheader out of it; the pill is a real <button>, and telling it that it opens a
313
+ listbox is what stops it announcing itself as a pressed switch.
314
+
315
+ No `active`: a pill that opens a list is not a filter that is on. See the Pill's
316
+ own header for that rule. -->
317
+ <sds-pill
318
+ key="trigger"
319
+ label=label
320
+ iconLeft=icon
321
+ iconRight=chevronIcon
322
+ onClick=() => component.toggle()
323
+ aria-haspopup="listbox"
324
+ aria-expanded=(open ? "true" : "false")
325
+ aria-controls=(open ? listboxId : null)
326
+ aria-label=(label ? null : ariaLabel)
327
+ />
328
+ </else-if>
329
+ <else-if(trigger === "iconButton")>
330
+ <!-- A glyph that opens a list — the "…" on a table row, the bell on a header. The
331
+ vocabulary has accepted `iconButton` since the port and there was no branch for
332
+ it: the trigger fell through to the nav button, so a caller who asked for a bare
333
+ icon got a labelled bar with a chevron, and `buttonIcon`, `iconVariant` and
334
+ `iconSize` were read by nothing at all. No error, just a different control.
335
+
336
+ The name is required, as it is on any icon button: a glyph carries none. -->
337
+ <sds-icon-button
338
+ key="trigger"
339
+ variant=(input.iconVariant ?? "neutral")
340
+ size=(input.iconSize ?? "md")
341
+ aria-label=(ariaLabel ?? label ?? "Menu")
342
+ aria-expanded=(open ? "true" : "false")
343
+ aria-haspopup="listbox"
344
+ aria-controls=(open ? listboxId : null)
345
+ onClick=() => component.toggle()
346
+ >
347
+ <@icon><${renderable(input.buttonIcon ?? icon ?? moreIcon)}/></@icon>
348
+ </sds-icon-button>
349
+ </else-if>
350
+ <else-if(trigger === "avatar")>
351
+ <!-- A face that opens a list — the account menu. The avatar is drawn by sds-avatar and
352
+ the button around it is what makes it a control: an <img> with an onclick announces
353
+ itself as an image, and a profile menu nobody can reach by keyboard is a profile
354
+ menu for some people only.
355
+
356
+ The name comes from `ariaLabel`, because the avatar itself is decorative here: its
357
+ initials are already in the panel header below, and hearing them twice says
358
+ nothing the second time. -->
359
+ <button
360
+ key="trigger"
361
+ type="button"
362
+ class="sds-menu__avatar-btn"
363
+ aria-expanded=(open ? "true" : "false")
364
+ aria-haspopup="listbox"
365
+ aria-controls=(open ? listboxId : null)
366
+ aria-label=(ariaLabel ?? name ?? "Conta")
367
+ on-click("toggle")
368
+ on-keydown("handleTriggerKey")
369
+ >
370
+ <sds-avatar src=input.src initials=input.initials size=(input.avatarSize ?? "sm") alt=""/>
371
+ </button>
372
+ </else-if>
373
+ <else>
374
+ <button
375
+ key="trigger"
376
+ type="button"
377
+ class=triggerClass
378
+ aria-expanded=(open ? "true" : "false")
379
+ aria-haspopup="listbox"
380
+ aria-controls=(open ? listboxId : null)
381
+ aria-label=(label ? null : ariaLabel)
382
+ aria-labelledby=(namesItself ? `${listboxId}-name ${listboxId}-value` : null)
383
+ on-click("toggle")
384
+ on-keydown("handleTriggerKey")
385
+ >
386
+ <if(icon)>
387
+ <span class="sds-menu__btn-icon" aria-hidden="true">
388
+ <sds-icon><${renderable(icon)}/></sds-icon>
389
+ </span>
390
+ </if>
391
+ <!-- When the visible label is a *value* rather than a name — a font size reading
392
+ "16" — the caller says what it is with aria-label and both are pointed at, so a
393
+ screen reader hears "Tamanho do texto 16" instead of "16, button". An aria-label
394
+ on its own would replace the value; the visible label on its own leaves the
395
+ value with nothing to say what it means. Same as the Select's nav trigger. -->
396
+ <if(namesItself)>
397
+ <span id=`${listboxId}-name` class="sds-visually-hidden">${ariaLabel}</span>
398
+ </if>
399
+ <if(label)>
400
+ <span id=(namesItself ? `${listboxId}-value` : null) class="sds-menu__btn-label">${label}</span>
401
+ </if>
402
+ <span class="sds-menu__btn-chevron" aria-hidden="true">
403
+ <sds-icon size="sm"><fluent-chevron-down-regular/></sds-icon>
404
+ </span>
405
+ </button>
406
+ </else>
407
+
408
+ <if(open)>
409
+ <div
410
+ key="panel"
411
+ class="sds-menu__panel"
412
+ style=(zIndex !== undefined ? `z-index:${zIndex}` : null)
413
+ on-keydown("handleListKey")
414
+ >
415
+ <!-- The list is wrapped, not placed directly in the panel. The wrapper is what
416
+ carries the panel's vertical padding and, with the panel's 480px cap, what
417
+ scrolls: without it a long menu is clipped at the cap with no way to reach the
418
+ rest of it. -->
419
+ <if(input.header)>
420
+ <div class="sds-menu__header">
421
+ <${input.header}/>
422
+ </div>
423
+ </if>
424
+
425
+ <div class="sds-menu__panel-body">
426
+ <!-- The list holds focus and names the row the arrows are on. Without
427
+ aria-activedescendant a screen reader hears the list and never the row. -->
428
+ <ul
429
+ class="sds-menu__list"
430
+ id=listboxId
431
+ role="listbox"
432
+ aria-label=ariaLabel
433
+ aria-activedescendant=(state.activeIndex >= 0 ? `${listboxId}-row-${state.activeIndex}` : null)
434
+ tabindex="-1"
435
+ >
436
+ <if(!rows.length)>
437
+ <li class="sds-menu__empty">${emptyMessage}</li>
438
+ </if>
439
+
440
+ <for|row, index| of=rows>
441
+ <if(row.type === "divider")>
442
+ <li class="sds-menu__separator" role="separator"/>
443
+ </if>
444
+ <else-if(row.type === "section")>
445
+ <li class="sds-menu__group-title" role="presentation">${row.label}</li>
446
+ </else-if>
447
+ <else-if(row.type === "custom")>
448
+ <!-- Markup in the panel rather than a choice in it — KDLS drops a
449
+ ColorPicker into a Menu.Group the same way. mousedown is stopped here
450
+ because the thing inside opens a panel of its own, and a click out
451
+ there would otherwise read as a click outside this one. -->
452
+ <li class="sds-menu__custom" role="presentation"
453
+ on-mousedown((event) => event.stopPropagation())>
454
+ <${row.renderBody}/>
455
+ </li>
456
+ </else-if>
457
+ <else>
458
+ <li role="none" class=(row.depth ? "sds-menu__child" : null)>
459
+ <macro|{ }| name="rowContent">
460
+ <if(row.icon)>
461
+ <span class="sds-menu__item-icon" aria-hidden="true">
462
+ <sds-icon size="sm"><${renderable(row.icon)}/></sds-icon>
463
+ </span>
464
+ </if>
465
+ <!-- An item can explain itself two ways, as in KDLS: the label becomes
466
+ the trigger, or a separate info affordance sits beside it. -->
467
+ <if(row.tooltip && !row.tooltipIcon)>
468
+ <sds-tooltip content=row.tooltip placement=(row.tooltipPosition ?? "top")>
469
+ <span class="sds-menu__item-label">
470
+ <if(row.renderBody)><${row.renderBody}/></if>
471
+ <else>${row.label}</else>
472
+ </span>
473
+ </sds-tooltip>
474
+ </if>
475
+ <else>
476
+ <span class="sds-menu__item-label">
477
+ <if(row.renderBody)><${row.renderBody}/></if>
478
+ <else>${row.label}</else>
479
+ </span>
480
+ </else>
481
+ <if(row.tooltip && row.tooltipIcon)>
482
+ <span class="sds-menu__item-tooltip-icon">
483
+ <sds-tooltip content=row.tooltip placement=(row.tooltipPosition ?? "top")/>
484
+ </span>
485
+ </if>
486
+ <if(row.badge)>
487
+ <sds-badge label=row.badge variant="scheme-color" appearance="tone-50" size="sm"/>
488
+ </if>
489
+ </macro>
490
+
491
+ <if(row.expandable)>
492
+ <!-- A row that opens the rows under it, in the panel rather than beside
493
+ it. A real <button>, so Enter and Space come from the browser rather
494
+ than from a keydown handler; `role="option"` because a listbox may
495
+ only hold options and groups, and ARIA allows `aria-expanded` on an
496
+ option. KDLS uses a <div role="option"> with its own key handling,
497
+ and nests a second listbox inside the first, which is not valid. -->
498
+ <button
499
+ type="button"
500
+ id=`${listboxId}-row-${index}`
501
+ class=`${rowClass(row)} sds-menu__item--expandable`
502
+ role="option"
503
+ aria-selected="false"
504
+ aria-expanded=(row.expanded ? "true" : "false")
505
+ disabled=(row.disabled || null)
506
+ data-active=(index === state.activeIndex ? "true" : null)
507
+ on-click(() => component.toggleRow(row.key))
508
+ >
509
+ <rowContent/>
510
+ <span class="sds-menu__item-chevron" aria-hidden="true">
511
+ <sds-icon size="sm"><fluent-chevron-down-regular/></sds-icon>
512
+ </span>
513
+ </button>
514
+ </if>
515
+ <else-if(row.href)>
516
+ <a
517
+ id=`${listboxId}-row-${index}`
518
+ class=rowClass(row)
519
+ role="option"
520
+ aria-selected=(row.active ? "true" : "false")
521
+ aria-disabled=(row.disabled ? "true" : null)
522
+ data-active=(index === state.activeIndex ? "true" : null)
523
+ href=(row.disabled ? null : row.href)
524
+ target=(row.targetBlank ? "_blank" : null)
525
+ rel=(row.targetBlank ? "noopener noreferrer" : null)
526
+ on-click("pick", row)
527
+ >
528
+ <rowContent/>
529
+ </a>
530
+ </else-if>
531
+ <else>
532
+ <button
533
+ type="button"
534
+ id=`${listboxId}-row-${index}`
535
+ class=rowClass(row)
536
+ role="option"
537
+ aria-selected=(row.active ? "true" : "false")
538
+ disabled=(row.disabled || null)
539
+ data-active=(index === state.activeIndex ? "true" : null)
540
+ on-click("pick", row)
541
+ >
542
+ <rowContent/>
543
+ </button>
544
+ </else>
545
+ </li>
546
+ </else>
547
+ </for>
548
+ </ul>
549
+ </div>
550
+
551
+ <!-- Pinned below the list rather than inside it, so it stays put while the rows
552
+ scroll — which is the whole point of a footer on a menu that can be long. -->
553
+ <if(input.footer)>
554
+ <div class="sds-menu__panel-footer">
555
+ <${input.footer}/>
556
+ </div>
557
+ </if>
558
+ </div>
559
+ </if>
560
+ </div>