@gjsify/adwaita-web 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,15 +1,19 @@
1
1
  {
2
2
  "name": "@gjsify/adwaita-web",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "description": "Adwaita/Libadwaita web components for browser targets",
5
5
  "type": "module",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "./src/index.ts",
7
+ "types": "./lib/types/index.d.ts",
8
8
  "exports": {
9
9
  ".": {
10
- "types": "./src/index.ts",
10
+ "types": "./lib/types/index.d.ts",
11
11
  "default": "./src/index.ts"
12
12
  },
13
+ "./source-view": {
14
+ "types": "./lib/types/source-view/index.d.ts",
15
+ "default": "./src/source-view/index.ts"
16
+ },
13
17
  "./style.css": {
14
18
  "types": "./style.css.d.ts",
15
19
  "default": "./dist/adwaita-web.css"
@@ -25,29 +29,35 @@
25
29
  "style.css.d.ts"
26
30
  ],
27
31
  "scripts": {
28
- "clear": "rm -rf lib dist tsconfig.tsbuildinfo scss/_icons.generated.scss || exit 0",
32
+ "clear": "rm -rf lib dist tsconfig.tsbuildinfo scss/_icons.generated.scss src/styles.generated.ts || exit 0",
29
33
  "check": "gjsify run build:scss && gjsify tsc --noEmit",
30
- "build": "gjsify run build:scss && gjsify run build:gjsify && gjsify run build:types",
34
+ "build": "gjsify run build:scss && gjsify run build:types",
31
35
  "build:scss": "node scripts/build-scss.mjs",
32
- "build:gjsify": "gjsify build --library 'src/**/*.ts' --exclude 'src/**/*.spec.ts'",
33
36
  "build:types": "gjsify tsc"
34
37
  },
35
38
  "dependencies": {
36
- "@gjsify/adwaita-fonts": "^0.14.0",
37
- "@gjsify/adwaita-icons": "^0.14.0"
39
+ "@codemirror/commands": "^6.0.0",
40
+ "@codemirror/language": "^6.0.0",
41
+ "@codemirror/state": "^6.0.0",
42
+ "@codemirror/view": "^6.0.0",
43
+ "@gjsify/adwaita-fonts": "^0.15.1",
44
+ "@gjsify/adwaita-icons": "^0.15.1",
45
+ "@lezer/highlight": "^1.0.0"
38
46
  },
39
47
  "devDependencies": {
40
- "@gjsify/cli": "^0.14.0",
48
+ "@gjsify/cli": "^0.15.1",
41
49
  "sass": "^1.100.0",
42
50
  "typescript": "^6.0.3"
43
51
  },
44
52
  "gjsify": {
53
+ "buildCache": false,
45
54
  "runtimes": {
46
55
  "gjs": "none",
47
56
  "node": "none",
48
57
  "browser": "polyfill",
49
58
  "nativescript": "none"
50
- }
59
+ },
60
+ "tier": 2
51
61
  },
52
62
  "license": "MIT",
53
63
  "repository": {
@@ -0,0 +1,151 @@
1
+ // _menu_button.scss — adw-menu-button styling: a flat (or circular) icon button
2
+ // that drops an Adwaita-styled popover menu (the web counterpart of
3
+ // Adw.MenuButton / Gtk.MenuButton). The button mirrors libadwaita's flat/circular
4
+ // `menubutton > button`; the popover reproduces `popover.menu > contents` with
5
+ // its soft triple-shadow surface and `modelbutton`-style item rows.
6
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_buttons.scss (menubutton flat/circular)
7
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_popovers.scss (popover > contents)
8
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_menus.scss (popover.menu modelbutton)
9
+ // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
10
+ // Modifications: Adapted for @gjsify/adwaita-web Web Components.
11
+
12
+ .adw-menu-button {
13
+ position: relative;
14
+ box-sizing: border-box;
15
+ display: inline-flex;
16
+ vertical-align: middle;
17
+
18
+ // The icon button — square with the button radius by default.
19
+ .adw-menu-button-button {
20
+ box-sizing: border-box;
21
+ display: inline-flex;
22
+ align-items: center;
23
+ justify-content: center;
24
+ min-width: 34px;
25
+ min-height: 34px;
26
+ margin: 0;
27
+ padding: 0 8px;
28
+ border: none;
29
+ border-radius: var(--button-radius);
30
+ background-color: var(--button-bg-color);
31
+ color: var(--window-fg-color);
32
+ cursor: pointer;
33
+ transition: background-color 0.1s ease;
34
+
35
+ .adw-menu-button-icon {
36
+ width: 16px;
37
+ height: 16px;
38
+ flex: none;
39
+ }
40
+
41
+ &:hover {
42
+ background-color: var(--button-hover-color);
43
+ }
44
+
45
+ &:active {
46
+ background-color: var(--button-active-color);
47
+ }
48
+
49
+ &:focus-visible {
50
+ outline: 2px solid var(--accent-color);
51
+ outline-offset: 1px;
52
+ }
53
+ }
54
+
55
+ // Flat — no resting fill (the header app-menu idiom).
56
+ &.flat .adw-menu-button-button {
57
+ background-color: transparent;
58
+ }
59
+
60
+ // The open state keeps a subtle fill so the button reads as "checked".
61
+ &.active .adw-menu-button-button {
62
+ background-color: var(--button-active-color);
63
+ }
64
+
65
+ // Circular — a round 34×34 button (window-control / FAB idiom).
66
+ &.circular .adw-menu-button-button {
67
+ padding: 0;
68
+ border-radius: 9999px;
69
+ }
70
+
71
+ &.disabled {
72
+ opacity: 0.45;
73
+
74
+ .adw-menu-button-button {
75
+ cursor: default;
76
+ pointer-events: none;
77
+ }
78
+ }
79
+
80
+ // --- Popover menu ---
81
+ // The elevated menu surface below the button, right-aligned like a header-end
82
+ // app menu. Reproduces libadwaita's popover.menu > contents.
83
+ .adw-menu-button-popover {
84
+ position: absolute;
85
+ top: calc(100% + 6px);
86
+ right: 0;
87
+ z-index: 10;
88
+ display: flex;
89
+ flex-direction: column;
90
+ min-width: 200px;
91
+ padding: var(--spacing-xs);
92
+ border-radius: var(--card-radius);
93
+ background-color: var(--popover-bg-color, var(--window-bg-color, #fff));
94
+ color: var(--popover-fg-color, var(--window-fg-color));
95
+ box-shadow:
96
+ 0 0 0 1px rgba(0, 0, 0, 0.05),
97
+ 0 1px 5px 1px rgba(0, 0, 0, 0.09),
98
+ 0 2px 14px 3px rgba(0, 0, 0, 0.05);
99
+
100
+ &[hidden] {
101
+ display: none;
102
+ }
103
+ }
104
+
105
+ // Optional section heading atop the menu (libadwaita label.title).
106
+ .adw-menu-button-title {
107
+ padding: 4px var(--spacing-s) 6px;
108
+ font-weight: 700;
109
+ opacity: var(--dim-opacity);
110
+ }
111
+
112
+ // A menu item row — a flat rounded row that fills on hover/active.
113
+ .adw-menu-button-item {
114
+ box-sizing: border-box;
115
+ display: flex;
116
+ align-items: center;
117
+ gap: var(--spacing-xs);
118
+ width: 100%;
119
+ min-height: 32px;
120
+ margin: 0;
121
+ padding: var(--spacing-xs) var(--spacing-s);
122
+ border: none;
123
+ border-radius: calc(var(--button-radius) - 2px);
124
+ background-color: transparent;
125
+ color: inherit;
126
+ font-family: var(--font-family);
127
+ font-size: var(--font-size-base);
128
+ text-align: start;
129
+ cursor: pointer;
130
+ transition: background-color 0.1s ease;
131
+
132
+ .adw-menu-button-item-icon {
133
+ width: 16px;
134
+ height: 16px;
135
+ flex: none;
136
+ }
137
+
138
+ &:hover {
139
+ background-color: var(--button-hover-color);
140
+ }
141
+
142
+ &:active {
143
+ background-color: var(--button-active-color);
144
+ }
145
+
146
+ &:focus-visible {
147
+ outline: 2px solid var(--accent-color);
148
+ outline-offset: -2px;
149
+ }
150
+ }
151
+ }
@@ -0,0 +1,22 @@
1
+ // _root.scss — opt-in page surface for chrome-less layouts.
2
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_window.scss (surface tokens)
3
+ // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
4
+ // Modifications: original opt-in helper for @gjsify/adwaita-web.
5
+ //
6
+ // The components theme via the `--window-*` CSS custom properties, but only
7
+ // `adw-window` actually PAINTS the window surface (background + text colour).
8
+ // A page laid out WITHOUT an `<adw-window>` (e.g. a plain browser view that
9
+ // hosts rows/cards directly) therefore renders on the browser default (white)
10
+ // and its native form controls / scrollbars don't follow the light/dark scheme.
11
+ //
12
+ // Applying `.adw-root` to `<body>` (or `<html>` / the `:root` element) binds the
13
+ // Adwaita window surface + `color-scheme: light dark` to the document, so a
14
+ // window-less layout is themed exactly like the components inside it. This is
15
+ // OPT-IN and additive — it does not change any component's own styling.
16
+ .adw-root {
17
+ background-color: var(--window-bg-color);
18
+ color: var(--window-fg-color);
19
+ color-scheme: light dark;
20
+ font-family: var(--font-family);
21
+ font-size: var(--font-size-base);
22
+ }
package/scss/_theme.scss CHANGED
@@ -18,6 +18,9 @@
18
18
  --card-fg-color: #ffffff;
19
19
  --card-shade-color: rgba(0, 0, 6, 0.36);
20
20
  --accent-color: #78aeed;
21
+ --popover-bg-color: #36363a;
22
+ --popover-fg-color: #ffffff;
23
+ --popover-shade-color: rgba(0, 0, 6, 0.36);
21
24
  --button-bg-color: rgba(255, 255, 255, 0.1);
22
25
  --button-hover-color: rgba(255, 255, 255, 0.15);
23
26
  --button-active-color: rgba(255, 255, 255, 0.22);
@@ -37,6 +37,11 @@
37
37
  --accent-fg-color: #ffffff;
38
38
  --accent-color: #1c71d8;
39
39
 
40
+ // Popover (menus, dropdowns) — an elevated surface above the window.
41
+ --popover-bg-color: #ffffff;
42
+ --popover-fg-color: rgba(0, 0, 6, 0.8);
43
+ --popover-shade-color: rgba(0, 0, 6, 0.07);
44
+
40
45
  // Buttons
41
46
  --button-bg-color: rgba(0, 0, 6, 0.08);
42
47
  --button-hover-color: rgba(0, 0, 6, 0.13);
@@ -0,0 +1,29 @@
1
+ // _view_stack.scss — adw-view-stack styling: a standalone named-page content
2
+ // container that shows exactly one page at a time (the web counterpart of
3
+ // Adw.ViewStack, decoupled from any switcher). It carries no chrome of its own —
4
+ // a paired adw-view-switcher-bar (or a header-bar switcher) drives selection.
5
+ // Reference: refs/libadwaita/src/adw-view-stack.c (AdwViewStack)
6
+ // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
7
+ // Modifications: Adapted for @gjsify/adwaita-web Web Components.
8
+
9
+ adw-view-stack {
10
+ display: flex;
11
+ flex-direction: column;
12
+ box-sizing: border-box;
13
+ // vexpand-equivalent — fill the space a switcher shell allots to content.
14
+ flex: 1 1 auto;
15
+ min-height: 0;
16
+
17
+ // Exactly one child is shown; the rest are laid out of flow. `hidden` is set
18
+ // in JS too, but the class keeps the visible page a flex child that grows.
19
+ .adw-view-stack-child {
20
+ display: none;
21
+
22
+ &.active-view {
23
+ display: flex;
24
+ flex-direction: column;
25
+ flex: 1 1 auto;
26
+ min-height: 0;
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,94 @@
1
+ // _view_switcher_bar.scss — adw-view-switcher-bar styling: the narrow BOTTOM
2
+ // bar of homogeneous icon-over-label buttons bound to an adw-view-stack (the web
3
+ // counterpart of Adw.ViewSwitcherBar). It sits at the window bottom with a top
4
+ // hairline over a headerbar-toned surface, mirroring libadwaita's `.narrow`
5
+ // view-switcher inside an actionbar. The active button's label is accent-tinted.
6
+ // Reference: refs/libadwaita/src/adw-view-switcher-bar.c (AdwViewSwitcherBar)
7
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_view-switcher.scss (.narrow policy)
8
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_toolbars.scss (actionbar top border)
9
+ // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
10
+ // Modifications: Adapted for @gjsify/adwaita-web Web Components.
11
+
12
+ adw-view-switcher-bar {
13
+ display: block;
14
+ box-sizing: border-box;
15
+ // The bottom bar over a headerbar-toned surface with a top hairline (the
16
+ // libadwaita `actionbar` inset top border), NOT a bottom border.
17
+ background-color: var(--headerbar-bg-color);
18
+ border-top: 1px solid var(--separator-color);
19
+ padding: 7px 0; // libadwaita `viewswitcherbar > box` vertical padding
20
+
21
+ // The homogeneous button row — every button gets equal width.
22
+ .adw-view-switcher-bar-box {
23
+ display: flex;
24
+ align-items: stretch;
25
+ // The `.narrow` view-switcher drops to a 6px min-height (icon-over-label).
26
+ min-height: 6px;
27
+ }
28
+
29
+ // Each button — icon stacked above a small label, a flat transparent toggle
30
+ // that fills on hover/active and marks the selected page with an accent
31
+ // label (the neutral fill stays the same across states, like libadwaita).
32
+ .adw-view-switcher-bar-button {
33
+ box-sizing: border-box;
34
+ display: inline-flex;
35
+ flex: 1 1 0; // homogeneous — equal width
36
+ flex-direction: column;
37
+ align-items: center;
38
+ justify-content: center;
39
+ gap: 1px;
40
+ margin: 0;
41
+ padding: 4px 3px 2px;
42
+ border: none;
43
+ border-radius: var(--button-radius);
44
+ background-color: transparent;
45
+ color: var(--window-fg-color);
46
+ font-family: var(--font-family);
47
+ font-size: 0.75rem;
48
+ font-weight: 700;
49
+ line-height: 1.2;
50
+ cursor: pointer;
51
+ transition:
52
+ color 0.2s ease,
53
+ background-color 0.2s ease;
54
+ user-select: none;
55
+ -webkit-user-select: none;
56
+
57
+ .adw-view-switcher-bar-icon {
58
+ width: 16px;
59
+ height: 16px;
60
+ flex: none;
61
+ }
62
+
63
+ .adw-view-switcher-bar-label {
64
+ min-height: 18px;
65
+ line-height: 18px;
66
+ white-space: nowrap;
67
+ overflow: hidden;
68
+ text-overflow: ellipsis;
69
+ }
70
+
71
+ &:hover {
72
+ background-color: var(--button-hover-color);
73
+ }
74
+
75
+ &:active {
76
+ background-color: var(--button-active-color);
77
+ }
78
+
79
+ &:focus-visible {
80
+ outline: 2px solid var(--accent-color);
81
+ outline-offset: -2px;
82
+ }
83
+
84
+ // The selected page — its label turns accent-coloured. (The icon stays
85
+ // theme-fg, matching the NS twin's documented fidelity note.)
86
+ &.active {
87
+ color: var(--accent-color);
88
+
89
+ .adw-view-switcher-bar-label {
90
+ color: var(--accent-color);
91
+ }
92
+ }
93
+ }
94
+ }
@@ -9,12 +9,14 @@
9
9
  @use 'icons.generated';
10
10
  @use 'variables';
11
11
  @use 'theme';
12
+ @use 'root';
12
13
  @use 'window';
13
14
  @use 'headerbar';
14
15
  @use 'window_title';
15
16
  @use 'button';
16
17
  @use 'button_content';
17
18
  @use 'split_button';
19
+ @use 'menu_button';
18
20
  @use 'toggle_group';
19
21
  @use 'entry';
20
22
  @use 'preferences';
@@ -42,6 +44,8 @@
42
44
  @use 'tab_view';
43
45
  @use 'view_switcher';
44
46
  @use 'inline_view_switcher';
47
+ @use 'view_stack';
48
+ @use 'view_switcher_bar';
45
49
  @use 'navigation_view';
46
50
  @use 'bottom_sheet';
47
51
  @use 'sidebar';
@@ -0,0 +1,247 @@
1
+ // <adw-menu-button> — An icon button that opens a popover menu, the web
2
+ // counterpart of Adw.MenuButton / a Gtk.MenuButton. By default it is the flat
3
+ // `open-menu-symbolic` primary/app-menu button used at the end of a header bar
4
+ // (About / Preferences / Keyboard Shortcuts / Quit …). Clicking it toggles a
5
+ // real Adwaita-styled popover positioned under the button; the popover is
6
+ // dismissed on an outside click or Escape and supports arrow-key navigation.
7
+ //
8
+ // Attributes:
9
+ // icon-name — symbolic icon (no `-symbolic` suffix), default `open-menu`.
10
+ // menu-title — optional heading shown atop the popover.
11
+ // menu — JSON array of items, each `{ "id"?, "label", "icon"? }`.
12
+ // disabled — boolean; a disabled button does not open the menu.
13
+ // flat — boolean style class (default: flat, like the header app menu).
14
+ // circular — boolean style class (round 34×34 button).
15
+ // Properties (mirroring Adw.MenuButton / the NS twin):
16
+ // menuItems — the menu entries ({ id?, label, icon? }[]) (get/set).
17
+ // menuTitle — the popover heading (get/set).
18
+ // active — whether the popover is open (get).
19
+ // Events:
20
+ // `menu-item-activated` (CustomEvent, bubbles, detail = { id, label, index })
21
+ // when an item is chosen — `id` falls back to `label` when omitted (matches
22
+ // the NS twin). The popover closes on activation.
23
+ // Reference: refs/libadwaita/src/adw-menu-button.c (AdwMenuButton)
24
+ // Reference: refs/libadwaita/src/stylesheet/widgets/_menus.scss (popover.menu / modelbutton)
25
+ // Reference: packages/nativescript-bridge/adwaita/src/widgets/adw-menu-button.ts (NS twin)
26
+ // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
27
+ // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
28
+
29
+ /** A menu entry. `id` defaults to `label` on activation; `icon` is optional. */
30
+ export interface AdwMenuItem {
31
+ id?: string;
32
+ label: string;
33
+ icon?: string;
34
+ }
35
+
36
+ export class AdwMenuButton extends HTMLElement {
37
+ private _buttonEl!: HTMLButtonElement;
38
+ private _popoverEl!: HTMLDivElement;
39
+ private _items: AdwMenuItem[] = [];
40
+ private _menuTitle = '';
41
+ private _active = false;
42
+ private _initialized = false;
43
+ private _itemButtons: HTMLButtonElement[] = [];
44
+ private _onDocumentPointerDown = (event: Event): void => {
45
+ if (!this.contains(event.target as Node)) this._setActive(false);
46
+ };
47
+ private _onDocumentKeyDown = (event: KeyboardEvent): void => {
48
+ if (event.key === 'Escape') {
49
+ this._setActive(false);
50
+ this._buttonEl.focus();
51
+ }
52
+ };
53
+
54
+ static get observedAttributes() {
55
+ return ['icon-name', 'menu-title', 'menu', 'disabled', 'flat', 'circular'];
56
+ }
57
+
58
+ /** Whether the popover menu is currently open. */
59
+ get active(): boolean {
60
+ return this._active;
61
+ }
62
+
63
+ /** The menu entries. Setting rebuilds the popover. */
64
+ get menuItems(): AdwMenuItem[] {
65
+ return this._items;
66
+ }
67
+
68
+ set menuItems(value: AdwMenuItem[]) {
69
+ this._items = Array.isArray(value) ? value.map((it) => ({ ...it })) : [];
70
+ if (this._initialized) this._renderMenu();
71
+ }
72
+
73
+ /** The optional popover heading. */
74
+ get menuTitle(): string {
75
+ return this.getAttribute('menu-title') ?? this._menuTitle;
76
+ }
77
+
78
+ set menuTitle(value: string) {
79
+ this._menuTitle = value ?? '';
80
+ this.setAttribute('menu-title', this._menuTitle);
81
+ }
82
+
83
+ connectedCallback() {
84
+ if (this._initialized) return;
85
+ this._initialized = true;
86
+
87
+ this.classList.add('adw-menu-button');
88
+
89
+ this._buttonEl = document.createElement('button');
90
+ this._buttonEl.type = 'button';
91
+ this._buttonEl.className = 'adw-menu-button-button';
92
+ this._buttonEl.setAttribute('aria-haspopup', 'menu');
93
+ this._buttonEl.setAttribute('aria-expanded', 'false');
94
+
95
+ this._popoverEl = document.createElement('div');
96
+ this._popoverEl.className = 'adw-menu-button-popover';
97
+ this._popoverEl.setAttribute('role', 'menu');
98
+ this._popoverEl.hidden = true;
99
+ this._popoverEl.addEventListener('keydown', (e) => this._onMenuKeyDown(e));
100
+
101
+ this.replaceChildren(this._buttonEl, this._popoverEl);
102
+
103
+ this._buttonEl.addEventListener('click', () => {
104
+ if (this.hasAttribute('disabled')) return;
105
+ this._setActive(!this._active);
106
+ });
107
+
108
+ // Seed items from the `menu` attribute if the property was not set.
109
+ if (this._items.length === 0) this._items = this._parseMenuAttr();
110
+ // Default to the flat header idiom unless another variant is requested.
111
+ // Set only now the DOM exists, so the observed-attribute callback (which
112
+ // renders) never runs before `_buttonEl` is created.
113
+ if (!this.hasAttribute('flat') && !this.hasAttribute('circular')) this.setAttribute('flat', '');
114
+ this._render();
115
+ }
116
+
117
+ disconnectedCallback() {
118
+ document.removeEventListener('pointerdown', this._onDocumentPointerDown);
119
+ document.removeEventListener('keydown', this._onDocumentKeyDown);
120
+ }
121
+
122
+ attributeChangedCallback(name: string) {
123
+ if (!this._initialized || !this._buttonEl) return;
124
+ if (name === 'menu') {
125
+ this._items = this._parseMenuAttr();
126
+ this._renderMenu();
127
+ return;
128
+ }
129
+ this._render();
130
+ }
131
+
132
+ private _parseMenuAttr(): AdwMenuItem[] {
133
+ const raw = this.getAttribute('menu');
134
+ if (!raw) return [];
135
+ try {
136
+ const parsed: unknown = JSON.parse(raw);
137
+ if (!Array.isArray(parsed)) return [];
138
+ return parsed
139
+ .filter((e): e is AdwMenuItem => typeof e === 'object' && e !== null && 'label' in e)
140
+ .map((e) => ({ id: e.id, label: String(e.label), icon: e.icon }));
141
+ } catch {
142
+ return [];
143
+ }
144
+ }
145
+
146
+ private _setActive(active: boolean): void {
147
+ if (this._active === active) return;
148
+ // Only open when there is something to show.
149
+ if (active && this._items.length === 0) return;
150
+ this._active = active;
151
+ this._popoverEl.hidden = !active;
152
+ this.classList.toggle('active', active);
153
+ this._buttonEl.setAttribute('aria-expanded', String(active));
154
+ if (active) {
155
+ document.addEventListener('pointerdown', this._onDocumentPointerDown);
156
+ document.addEventListener('keydown', this._onDocumentKeyDown);
157
+ // Move focus into the menu for keyboard navigation.
158
+ this._itemButtons[0]?.focus();
159
+ } else {
160
+ document.removeEventListener('pointerdown', this._onDocumentPointerDown);
161
+ document.removeEventListener('keydown', this._onDocumentKeyDown);
162
+ }
163
+ }
164
+
165
+ private _onMenuKeyDown(event: KeyboardEvent): void {
166
+ const buttons = this._itemButtons;
167
+ if (buttons.length === 0) return;
168
+ const current = buttons.indexOf(document.activeElement as HTMLButtonElement);
169
+ let next = -1;
170
+ if (event.key === 'ArrowDown') next = (current + 1) % buttons.length;
171
+ else if (event.key === 'ArrowUp') next = (current - 1 + buttons.length) % buttons.length;
172
+ else if (event.key === 'Home') next = 0;
173
+ else if (event.key === 'End') next = buttons.length - 1;
174
+ else return;
175
+ event.preventDefault();
176
+ buttons[next]?.focus();
177
+ }
178
+
179
+ private _render(): void {
180
+ const disabled = this.hasAttribute('disabled');
181
+ this.classList.toggle('disabled', disabled);
182
+ this._buttonEl.disabled = disabled;
183
+ this.classList.toggle('flat', this.hasAttribute('flat'));
184
+ this.classList.toggle('circular', this.hasAttribute('circular'));
185
+
186
+ const icon = (this.getAttribute('icon-name') ?? 'open-menu').replace(/-symbolic$/, '');
187
+ this._buttonEl.replaceChildren();
188
+ const iconEl = document.createElement('span');
189
+ iconEl.className = `adw-icon adw-menu-button-icon${icon ? ` adw-icon--${icon}` : ''}`;
190
+ iconEl.setAttribute('aria-hidden', 'true'); // decorative (mask-image only)
191
+ this._buttonEl.appendChild(iconEl);
192
+ this._buttonEl.setAttribute('aria-label', this.getAttribute('menu-title') || 'Menu');
193
+
194
+ this._renderMenu();
195
+ }
196
+
197
+ private _renderMenu(): void {
198
+ this._popoverEl.replaceChildren();
199
+ this._itemButtons = [];
200
+
201
+ const title = this.getAttribute('menu-title') ?? this._menuTitle;
202
+ if (title) {
203
+ const titleEl = document.createElement('div');
204
+ titleEl.className = 'adw-menu-button-title';
205
+ titleEl.textContent = title;
206
+ this._popoverEl.appendChild(titleEl);
207
+ }
208
+
209
+ for (const [index, entry] of this._items.entries()) {
210
+ const item = document.createElement('button');
211
+ item.type = 'button';
212
+ item.className = 'adw-menu-button-item';
213
+ item.setAttribute('role', 'menuitem');
214
+ item.tabIndex = -1;
215
+
216
+ const iconName = (entry.icon ?? '').replace(/-symbolic$/, '');
217
+ if (iconName) {
218
+ const iconEl = document.createElement('span');
219
+ iconEl.className = `adw-icon adw-menu-button-item-icon adw-icon--${iconName}`;
220
+ iconEl.setAttribute('aria-hidden', 'true');
221
+ item.appendChild(iconEl);
222
+ }
223
+ const labelEl = document.createElement('span');
224
+ labelEl.className = 'adw-menu-button-item-label';
225
+ labelEl.textContent = entry.label;
226
+ item.appendChild(labelEl);
227
+
228
+ item.addEventListener('click', () => {
229
+ this._setActive(false);
230
+ this._buttonEl.focus();
231
+ this.dispatchEvent(
232
+ new CustomEvent('menu-item-activated', {
233
+ bubbles: true,
234
+ detail: { id: entry.id ?? entry.label, label: entry.label, index },
235
+ }),
236
+ );
237
+ });
238
+ this._popoverEl.appendChild(item);
239
+ this._itemButtons.push(item);
240
+ }
241
+
242
+ // A menu-button with no entries can never open.
243
+ if (this._items.length === 0) this._setActive(false);
244
+ }
245
+ }
246
+
247
+ customElements.define('adw-menu-button', AdwMenuButton);