@gjsify/adwaita-web 0.51.0 → 0.52.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 (103) hide show
  1. package/dist/adwaita-web.css +255 -27
  2. package/dist/adwaita-web.css.map +1 -1
  3. package/lib/types/blueprint-layout.spec.d.ts +1 -0
  4. package/lib/types/blueprint-markup.spec.d.ts +4 -0
  5. package/lib/types/blueprint-tree.spec.d.ts +1 -0
  6. package/lib/types/elements/adw-bottom-sheet.d.ts +31 -13
  7. package/lib/types/elements/adw-carousel.d.ts +5 -0
  8. package/lib/types/elements/adw-header-bar.d.ts +15 -0
  9. package/lib/types/elements/adw-inline-view-switcher.d.ts +1 -0
  10. package/lib/types/elements/adw-navigation-view.d.ts +1 -0
  11. package/lib/types/elements/adw-sidebar.d.ts +10 -0
  12. package/lib/types/elements/adw-tab-view.d.ts +1 -0
  13. package/lib/types/elements/adw-toggle-group.d.ts +11 -0
  14. package/lib/types/elements/gtk-action-bar.d.ts +19 -0
  15. package/lib/types/elements/gtk-box.d.ts +17 -0
  16. package/lib/types/elements/gtk-button.d.ts +2 -0
  17. package/lib/types/elements/gtk-label.d.ts +122 -0
  18. package/lib/types/gio-menu.spec.d.ts +1 -0
  19. package/lib/types/gtk-box.spec.d.ts +1 -0
  20. package/lib/types/gtk-label.spec.d.ts +1 -0
  21. package/lib/types/gtk-value-doors.spec.d.ts +1 -0
  22. package/lib/types/index.d.ts +4 -1
  23. package/lib/types/namespace/gio.d.ts +1 -0
  24. package/lib/types/namespace/gtk.d.ts +5 -0
  25. package/lib/types/shared-tree-builder.d.ts +64 -0
  26. package/lib/types/source-view/asm6502.d.ts +1 -20
  27. package/lib/types/source-view/index.d.ts +3 -2
  28. package/lib/types/source-view/theme.d.ts +1 -1
  29. package/lib/types/string-list-slot.d.ts +4 -0
  30. package/lib/types/styles.generated.d.ts +1 -1
  31. package/lib/types/tags.spec.d.ts +1 -0
  32. package/lib/types/value-lists.spec.d.ts +1 -0
  33. package/package.json +8 -6
  34. package/scss/_action_bar.scss +51 -0
  35. package/scss/_banner.scss +8 -1
  36. package/scss/_bottom_sheet.scss +57 -0
  37. package/scss/_box.scss +23 -0
  38. package/scss/_button.scss +29 -7
  39. package/scss/_button_content.scss +6 -1
  40. package/scss/_button_row.scss +5 -2
  41. package/scss/_carousel.scss +6 -0
  42. package/scss/_labels.scss +126 -12
  43. package/scss/_navigation_split_view.scss +18 -0
  44. package/scss/_overlay_split_view.scss +5 -0
  45. package/scss/_reset.scss +29 -4
  46. package/scss/_split_button.scss +5 -2
  47. package/scss/_status_page.scss +31 -17
  48. package/scss/_theme.scss +11 -3
  49. package/scss/_toolbar_view.scss +10 -0
  50. package/scss/_variables.scss +1 -0
  51. package/scss/_widget.scss +55 -0
  52. package/scss/_window_title.scss +9 -0
  53. package/scss/adwaita-skin.scss +8 -2
  54. package/src/adw-banner.spec.ts +16 -0
  55. package/src/adw-button-row.spec.ts +8 -0
  56. package/src/adw-header-bar.spec.ts +17 -0
  57. package/src/blueprint-layout.spec.ts +223 -0
  58. package/src/blueprint-markup.spec.ts +155 -0
  59. package/src/blueprint-tree.spec.ts +174 -0
  60. package/src/bottom-sheet.spec.ts +166 -1
  61. package/src/chrome.spec.ts +5 -2
  62. package/src/elements/adw-bottom-sheet.ts +166 -59
  63. package/src/elements/adw-carousel.ts +10 -5
  64. package/src/elements/adw-clamp.ts +12 -0
  65. package/src/elements/adw-combo-row.ts +13 -4
  66. package/src/elements/adw-header-bar.ts +46 -3
  67. package/src/elements/adw-inline-view-switcher.ts +11 -0
  68. package/src/elements/adw-navigation-view.ts +10 -0
  69. package/src/elements/adw-sidebar.ts +22 -0
  70. package/src/elements/adw-spin-row.ts +44 -5
  71. package/src/elements/adw-status-page.ts +12 -6
  72. package/src/elements/adw-tab-view.ts +20 -3
  73. package/src/elements/adw-toggle-group.ts +45 -4
  74. package/src/elements/adw-toolbar-view.ts +9 -0
  75. package/src/elements/gtk-action-bar.ts +90 -0
  76. package/src/elements/gtk-box.ts +87 -0
  77. package/src/elements/gtk-button.ts +60 -13
  78. package/src/elements/gtk-drop-down.ts +10 -1
  79. package/src/elements/gtk-label.ts +415 -0
  80. package/src/gio-menu.spec.ts +125 -0
  81. package/src/globals.d.ts +7 -0
  82. package/src/gtk-box.spec.ts +168 -0
  83. package/src/gtk-button.spec.ts +123 -1
  84. package/src/gtk-label.spec.ts +595 -0
  85. package/src/gtk-value-doors.spec.ts +104 -0
  86. package/src/index.ts +14 -1
  87. package/src/keyboard-operable.spec.ts +2 -1
  88. package/src/namespace/gio.ts +19 -0
  89. package/src/namespace/gtk.ts +14 -0
  90. package/src/shared-tree-builder.ts +268 -0
  91. package/src/shared-trees.spec.ts +296 -32
  92. package/src/source-view/adw-source-view.ts +3 -3
  93. package/src/source-view/asm6502.ts +11 -191
  94. package/src/source-view/index.ts +17 -11
  95. package/src/source-view/theme.ts +16 -16
  96. package/src/split-button.spec.ts +7 -0
  97. package/src/string-list-slot.ts +21 -0
  98. package/src/styles.generated.ts +1 -1
  99. package/src/tags.spec.ts +51 -0
  100. package/src/test.browser.mts +18 -0
  101. package/src/value-lists.spec.ts +93 -0
  102. package/lib/types/source-view/hex.d.ts +0 -21
  103. package/src/source-view/hex.ts +0 -37
@@ -0,0 +1,155 @@
1
+ // The custom-element markup of the `index.html` file the gallery's Web Components tab shows for
2
+ // a one-Blueprint block, PARSED back by the browser and held to the tree `buildSharedTree`
3
+ // makes from the same `.blp`.
4
+ //
5
+ // The website renders that markup with `@gjsify/adwaita-core/markup` from the block's
6
+ // `?shared-tree` projection, on every build. Markup a reader copies has to build the same
7
+ // widget: an attribute spelled against `attributeOf`, a boolean written as `"true"` where the
8
+ // element reads presence, or a slot dropped would each parse fine and mount something else. So every `.blp` under
9
+ // `website/src/blueprints/` is imported below — `scripts/check-website-blueprint-markup.mjs`
10
+ // fails on one that is not.
11
+
12
+ import { describe, expect, it } from '@gjsify/unit';
13
+
14
+ import type { SharedTreeNode } from '@gjsify/adwaita-core/conformance';
15
+ import { sharedTreeHtml } from '@gjsify/adwaita-core/markup';
16
+ import { hostTagOf } from '@gjsify/adwaita-core/tags';
17
+
18
+ import adwaitaActionRowTree from '../../../../website/src/blueprints/adwaita/action-row.blp?shared-tree';
19
+ import adwaitaAvatarTree from '../../../../website/src/blueprints/adwaita/avatar.blp?shared-tree';
20
+ import adwaitaBannerTree from '../../../../website/src/blueprints/adwaita/banner.blp?shared-tree';
21
+ import adwaitaBottomSheetTree from '../../../../website/src/blueprints/adwaita/bottom-sheet.blp?shared-tree';
22
+ import adwaitaButtonContentTree from '../../../../website/src/blueprints/adwaita/button-content.blp?shared-tree';
23
+ import adwaitaButtonRowTree from '../../../../website/src/blueprints/adwaita/button-row.blp?shared-tree';
24
+ import adwaitaCarouselTree from '../../../../website/src/blueprints/adwaita/carousel.blp?shared-tree';
25
+ import clampTree from '../../../../website/src/blueprints/adwaita/clamp.blp?shared-tree';
26
+ import adwaitaComboRowTree from '../../../../website/src/blueprints/adwaita/combo-row.blp?shared-tree';
27
+ import adwaitaEntryRowTree from '../../../../website/src/blueprints/adwaita/entry-row.blp?shared-tree';
28
+ import adwaitaExpanderRowTree from '../../../../website/src/blueprints/adwaita/expander-row.blp?shared-tree';
29
+ import adwaitaHeaderBarTree from '../../../../website/src/blueprints/adwaita/header-bar.blp?shared-tree';
30
+ import adwaitaNavigationSplitViewTree from '../../../../website/src/blueprints/adwaita/navigation-split-view.blp?shared-tree';
31
+ import adwaitaNavigationViewTree from '../../../../website/src/blueprints/adwaita/navigation-view.blp?shared-tree';
32
+ import adwaitaOverlaySplitViewTree from '../../../../website/src/blueprints/adwaita/overlay-split-view.blp?shared-tree';
33
+ import adwaitaPasswordEntryRowTree from '../../../../website/src/blueprints/adwaita/password-entry-row.blp?shared-tree';
34
+ import adwaitaPreferencesGroupTree from '../../../../website/src/blueprints/adwaita/preferences-group.blp?shared-tree';
35
+ import adwaitaShortcutLabelTree from '../../../../website/src/blueprints/adwaita/shortcut-label.blp?shared-tree';
36
+ import adwaitaSidebarTree from '../../../../website/src/blueprints/adwaita/sidebar.blp?shared-tree';
37
+ import adwaitaSpinRowTree from '../../../../website/src/blueprints/adwaita/spin-row.blp?shared-tree';
38
+ import adwaitaSplitButtonTree from '../../../../website/src/blueprints/adwaita/split-button.blp?shared-tree';
39
+ import adwaitaStatusPageTree from '../../../../website/src/blueprints/adwaita/status-page.blp?shared-tree';
40
+ import adwaitaSwitchRowTree from '../../../../website/src/blueprints/adwaita/switch-row.blp?shared-tree';
41
+ import adwaitaToggleGroupTree from '../../../../website/src/blueprints/adwaita/toggle-group.blp?shared-tree';
42
+ import adwaitaToolbarViewTree from '../../../../website/src/blueprints/adwaita/toolbar-view.blp?shared-tree';
43
+ import adwaitaViewSwitcherBarTree from '../../../../website/src/blueprints/adwaita/view-switcher-bar.blp?shared-tree';
44
+ import adwaitaWindowTitleTree from '../../../../website/src/blueprints/adwaita/window-title.blp?shared-tree';
45
+ import adwaitaWrapBoxTree from '../../../../website/src/blueprints/adwaita/wrap-box.blp?shared-tree';
46
+ import gtkButtonTree from '../../../../website/src/blueprints/gtk/button.blp?shared-tree';
47
+ import gtkMenuButtonTree from '../../../../website/src/blueprints/gtk/menu-button.blp?shared-tree';
48
+
49
+ import { buildSharedTree } from './shared-tree-builder.js';
50
+
51
+ /** Every one-Blueprint `.blp` of the gallery, by its path under `website/src/blueprints/`. */
52
+ export const GALLERY_BLUEPRINTS: Readonly<Record<string, SharedTreeNode>> = {
53
+ 'adwaita/action-row.blp': adwaitaActionRowTree,
54
+ 'adwaita/avatar.blp': adwaitaAvatarTree,
55
+ 'adwaita/banner.blp': adwaitaBannerTree,
56
+ 'adwaita/bottom-sheet.blp': adwaitaBottomSheetTree,
57
+ 'adwaita/button-content.blp': adwaitaButtonContentTree,
58
+ 'adwaita/button-row.blp': adwaitaButtonRowTree,
59
+ 'adwaita/carousel.blp': adwaitaCarouselTree,
60
+ 'adwaita/clamp.blp': clampTree,
61
+ 'adwaita/combo-row.blp': adwaitaComboRowTree,
62
+ 'adwaita/entry-row.blp': adwaitaEntryRowTree,
63
+ 'adwaita/expander-row.blp': adwaitaExpanderRowTree,
64
+ 'adwaita/header-bar.blp': adwaitaHeaderBarTree,
65
+ 'adwaita/navigation-split-view.blp': adwaitaNavigationSplitViewTree,
66
+ 'adwaita/navigation-view.blp': adwaitaNavigationViewTree,
67
+ 'adwaita/overlay-split-view.blp': adwaitaOverlaySplitViewTree,
68
+ 'adwaita/password-entry-row.blp': adwaitaPasswordEntryRowTree,
69
+ 'adwaita/preferences-group.blp': adwaitaPreferencesGroupTree,
70
+ 'adwaita/shortcut-label.blp': adwaitaShortcutLabelTree,
71
+ 'adwaita/sidebar.blp': adwaitaSidebarTree,
72
+ 'adwaita/spin-row.blp': adwaitaSpinRowTree,
73
+ 'adwaita/split-button.blp': adwaitaSplitButtonTree,
74
+ 'adwaita/status-page.blp': adwaitaStatusPageTree,
75
+ 'adwaita/switch-row.blp': adwaitaSwitchRowTree,
76
+ 'adwaita/toggle-group.blp': adwaitaToggleGroupTree,
77
+ 'adwaita/toolbar-view.blp': adwaitaToolbarViewTree,
78
+ 'adwaita/view-switcher-bar.blp': adwaitaViewSwitcherBarTree,
79
+ 'adwaita/window-title.blp': adwaitaWindowTitleTree,
80
+ 'adwaita/wrap-box.blp': adwaitaWrapBoxTree,
81
+ 'gtk/button.blp': gtkButtonTree,
82
+ 'gtk/menu-button.blp': gtkMenuButtonTree,
83
+ };
84
+
85
+ /**
86
+ * An attribute's value as the comparison reads it. `style` is read as the declarations it
87
+ * sets, one longhand each and sorted: which of the builder's margin and an element's own
88
+ * `flex-direction` was written first, and whether the serialiser folds two margins into a
89
+ * `margin-inline`, is text neither side chose and no style resolves differently for.
90
+ */
91
+ const attributeText = (el: Element, attribute: Attr): string => {
92
+ if (attribute.name !== 'style') return attribute.value;
93
+ const style = (el as HTMLElement).style;
94
+ return [...style]
95
+ .map((property) => `${property}: ${style.getPropertyValue(property)}`)
96
+ .sort()
97
+ .join('; ');
98
+ };
99
+
100
+ /**
101
+ * An element tree as text a comparison can hold: the tag, the attributes SORTED (their order is
102
+ * the one thing markup and a builder may legitimately disagree on, and no element reads it), and
103
+ * the children, with the indentation between elements dropped since the builder never makes any.
104
+ */
105
+ const canonical = (el: Element, depth = 0): string => {
106
+ const attributes = [...el.attributes].map((a) => `${a.name}=${JSON.stringify(attributeText(el, a))}`).sort();
107
+ const pad = ' '.repeat(depth);
108
+ const lines = [`${pad}<${el.localName}${attributes.map((a) => ` ${a}`).join('')}>`];
109
+ for (const node of el.childNodes) {
110
+ if (node.nodeType === Node.ELEMENT_NODE) lines.push(canonical(node as Element, depth + 1));
111
+ else if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim() !== '') {
112
+ lines.push(`${pad} ${JSON.stringify(node.textContent)}`);
113
+ }
114
+ }
115
+ return lines.join('\n');
116
+ };
117
+
118
+ const withIds = (node: SharedTreeNode, into: SharedTreeNode[] = []): SharedTreeNode[] => {
119
+ if (node.id !== undefined) into.push(node);
120
+ for (const child of node.children ?? []) withIds(child, into);
121
+ return into;
122
+ };
123
+
124
+ export const AdwBlueprintMarkupTest = async () => {
125
+ await describe('adwaita-web: the gallery’s markup, parsed', async () => {
126
+ for (const [file, tree] of Object.entries(GALLERY_BLUEPRINTS)) {
127
+ // Both sides DETACHED and upgraded: a detached build runs each element's constructor
128
+ // and attribute callbacks (a label renders its text on `label=`), and so does parsing
129
+ // in this document. Neither is connected, so what differs is only what was authored.
130
+ await it(`${file}: parses to the element tree buildSharedTree makes`, async () => {
131
+ const parsed = document.createRange().createContextualFragment(sharedTreeHtml(tree));
132
+ expect(canonical(parsed.firstElementChild!)).toBe(canonical(buildSharedTree(tree)));
133
+ });
134
+
135
+ await it(`${file}: mounted, every id reaches an upgraded element of its tag`, async () => {
136
+ const host = document.createElement('div');
137
+ host.innerHTML = sharedTreeHtml(tree);
138
+ document.body.append(host);
139
+ try {
140
+ const nodes = withIds(tree);
141
+ expect(nodes.length > 0).toBe(true);
142
+ for (const node of nodes) {
143
+ const found = host.querySelector(`#${node.id}`);
144
+ const tag = hostTagOf(node.tag);
145
+ expect(found?.localName).toBe(tag);
146
+ const defined = customElements.get(tag);
147
+ expect(defined !== undefined && found instanceof defined).toBe(true);
148
+ }
149
+ } finally {
150
+ host.remove();
151
+ }
152
+ });
153
+ }
154
+ });
155
+ };
@@ -0,0 +1,174 @@
1
+ // ONE `.blp`, RENDERED BY THIS RENDERER — the acceptance case for ADR 0053 clause 1's second
2
+ // exit, taken all the way through a build.
3
+ //
4
+ // `./shared-trees.spec.ts` drives ADR 0051's corpus, which is authored as `SharedTreeNode`
5
+ // literals in `scripts/adwaita-gallery-shared-trees.mjs`. This file drives the OTHER reader of
6
+ // the same shape: a Blueprint file, parsed and projected by `@gjsify/blueprint` and handed over
7
+ // by `@gjsify/vite-plugin-blueprint`'s `?shared-tree` exit at build time. So what is under test
8
+ // is not the builder — that is the sibling's job — it is the JOIN: that a `.blp` a GTK showcase
9
+ // actually ships arrives on this target as a tree this renderer mounts, with the fields ADRs
10
+ // 0066, 0067 and 0068 put on the node still on it — `template`/`object-id`, the translatable
11
+ // marking, and the style classes. Naming two of the three read as a complete list once 0068
12
+ // landed, which is how a reader learns a field is not carried when it is.
13
+ //
14
+ // WHY A SHIPPED FILE AND NOT A FIXTURE. A fixture written for this test would be written to
15
+ // pass it. `showcases/gtk/effect-adw-services/src/window.blp` is a real interface — an
16
+ // `Adw.ToolbarView` with a header bar, a clamp, three preference groups and named rows — and one
17
+ // of the shipped `.blp` whose projection declares NO loss, so nothing about it is refused before
18
+ // it can be rendered. The reach across the tree is the shape the NativeScript driver already uses
19
+ // for the corpus module (`packages/nativescript-bridge/adwaita/src/shared-trees.spec.ts`), for
20
+ // the same reason: the thing under test lives outside the package that renders it.
21
+ //
22
+ // WHAT THIS MEASURED THAT NOTHING HAD, AND WHAT CLOSED IT: SLOT PLACEMENT WAS NOT CARRIED, AND
23
+ // IT COST CAPTIONS. `SharedTreeNode.slot` was on the node shape and read by none of the three
24
+ // tree builders. That went unnoticed because ADR 0051's seven-block corpus authors zero slots —
25
+ // a `.blp` is the first source that authors any, and this one authors four. The two cases below
26
+ // were `it.failing` markers for exactly this gap — the header bar authored `[top]` landing in the
27
+ // toolbar view's CONTENT instead, and the `title-widget:` window title then discarded by
28
+ // `adw-header-bar`'s own build, which derives a title element of its own when its centre is
29
+ // empty — until the builders were given a slot reader (`shared-tree-builder.ts`,
30
+ // `adw-header-bar.ts`, `adw-toolbar-view.ts`); both are plain `it()` now that placement holds.
31
+ //
32
+ // Giving `slot` a reader is a SHARED-VOCABULARY decision and not wiring: `content:`, `[top]` and
33
+ // `title-widget:` are GtkBuilder's names, this renderer's are the unnamed slot, `top` and
34
+ // `center`, and a table mapping one to the other inside one renderer is the per-surface
35
+ // translator ADR 0051 § Alternatives rejected turned down on a measurement. Tracked in
36
+ // `status/open-todos.md`.
37
+
38
+ import { describe, expect, it } from '@gjsify/unit';
39
+
40
+ import { hostTagOf } from '@gjsify/adwaita-core/tags';
41
+
42
+ import { mountSharedTree } from './shared-tree-builder.js';
43
+
44
+ // The build seam itself. `?shared-tree` is the projection; the same file without the query is
45
+ // the GtkBuilder XML the GTK showcase compiles, and neither import moves the other.
46
+ import windowTree from '../../../../showcases/gtk/effect-adw-services/src/window.blp?shared-tree';
47
+
48
+ type Node = typeof windowTree;
49
+
50
+ /** Every tag the authored tree names, in document order, so nothing is transcribed. */
51
+ const authoredTags = (node: Node, into: string[] = []): string[] => {
52
+ into.push(node.tag);
53
+ for (const child of node.children ?? []) authoredTags(child, into);
54
+ return into;
55
+ };
56
+
57
+ /** Every prop value the source marked `_()`, read off the tree rather than off the `.blp`. */
58
+ const markedCaptions = (node: Node, into: string[] = []): string[] => {
59
+ for (const prop of Object.keys(node.translatable ?? {})) into.push(String(node.props?.[prop]));
60
+ for (const child of node.children ?? []) markedCaptions(child, into);
61
+ return into;
62
+ };
63
+
64
+ export const AdwBlueprintTreeTest = async () => {
65
+ await describe('adwaita-web: a shipped .blp, mounted', async () => {
66
+ await it('arrives as a projected node with its composite class and markings', async () => {
67
+ // The three fields ADRs 0066 and 0067 added are what make a `.blp` worth reading at
68
+ // all: without them the root of every shipped file projected to its PARENT type and
69
+ // said nothing about the class the file is about.
70
+ expect(windowTree.tag).toBe('AdwApplicationWindow');
71
+ expect(windowTree.template).toBe('EffectServicesWindow');
72
+ expect(windowTree.props?.['default-width']).toBe(620);
73
+ expect(windowTree.translatable?.title !== undefined).toBe(true);
74
+ // Seven of the fourteen nodes are placed by slot, which is what makes this file the
75
+ // probe the corpus could not be.
76
+ expect(authoredTags(windowTree).length).toBe(14);
77
+ });
78
+
79
+ await it('mounts the content subtree exactly as authored', async () => {
80
+ // FROM THE SCROLLED WINDOW DOWN, and the bound is the measurement rather than
81
+ // convenience: everything the `.blp` places at `content:` on the toolbar view is
82
+ // reached through the UNNAMED slot, which is the one placement this renderer and
83
+ // GtkBuilder spell the same way. Asserting the whole tree here would PASS and prove
84
+ // nothing — `adw-header-bar` derives a title element of its own, so the filtered
85
+ // sequence has one `adw-window-title` either way and the authored one can vanish
86
+ // with the count intact. That node is held by the caption case below instead.
87
+ const { root, unmount } = mountSharedTree(windowTree);
88
+ try {
89
+ const authored = new Set(authoredTags(windowTree).map(hostTagOf));
90
+ const realised = [...root.querySelectorAll('*')]
91
+ .map((el) => el.localName)
92
+ .filter((name) => authored.has(name));
93
+ const from = realised.indexOf('gtk-scrolled-window');
94
+ expect(from >= 0).toBe(true);
95
+ expect(realised.slice(from).join(' ')).toBe(
96
+ 'gtk-scrolled-window adw-clamp gtk-box adw-preferences-group adw-entry-row ' +
97
+ 'adw-preferences-group adw-action-row adw-preferences-group adw-action-row adw-action-row',
98
+ );
99
+ } finally {
100
+ unmount();
101
+ }
102
+ });
103
+
104
+ await it('upgrades the custom elements it mounted, and carries the authored props', async () => {
105
+ const { root, unmount } = mountSharedTree(windowTree);
106
+ try {
107
+ // UPGRADED, not merely created: an element this package defines is an instance
108
+ // of its own class once connected. An unknown tag would be a plain HTMLElement,
109
+ // and that is what tells the two apart.
110
+ const defined = customElements.get('adw-preferences-group');
111
+ expect(defined !== undefined).toBe(true);
112
+ expect(root.querySelector('adw-preferences-group') instanceof defined!).toBe(true);
113
+
114
+ // An authored property, through `attributeOf`'s case rule and nothing else.
115
+ expect(root.getAttribute('default-width')).toBe('620');
116
+ expect(root.querySelector('adw-clamp')?.getAttribute('maximum-size')).toBe('560');
117
+
118
+ // And a caption that reached the SCREEN, not just an attribute: the element read
119
+ // it on connect and wrote it into its own label.
120
+ expect(root.querySelector('adw-preferences-group .adw-preferences-group-title')?.textContent).toBe(
121
+ 'Read a directory',
122
+ );
123
+ expect(root.querySelector('adw-action-row .adw-row-subtitle')?.textContent).toBe('Type a path above.');
124
+ } finally {
125
+ unmount();
126
+ }
127
+ });
128
+
129
+ await it('places a [top] child in the bar the .blp named', async () => {
130
+ const { root, unmount } = mountSharedTree(windowTree);
131
+ try {
132
+ const headerBar = root.querySelector('adw-header-bar');
133
+ expect(headerBar?.parentElement?.className).toBe('adw-toolbar-view-top');
134
+ } finally {
135
+ unmount();
136
+ }
137
+ });
138
+
139
+ // The TypeScript beside a `.blp` addresses a widget by its Blueprint id, as GTK's
140
+ // `InternalChildren` does. An id the builder dropped would leave that code nothing to find.
141
+ await it('reaches a widget by the id the .blp gave it', async () => {
142
+ const { root, unmount } = mountSharedTree(windowTree);
143
+ try {
144
+ expect(root.querySelector('#pathRow')?.localName).toBe('adw-entry-row');
145
+ } finally {
146
+ unmount();
147
+ }
148
+ });
149
+
150
+ // This `.blp` authors no `styles [ ]`, so the tree is written out here.
151
+ await it('carries the style classes a tree authors', async () => {
152
+ const { root, unmount } = mountSharedTree({ tag: 'AdwClamp', styleClasses: ['card', 'dim-label'] });
153
+ try {
154
+ expect([...root.classList].filter((name) => name === 'card' || name === 'dim-label')).toStrictEqual([
155
+ 'card',
156
+ 'dim-label',
157
+ ]);
158
+ } finally {
159
+ unmount();
160
+ }
161
+ });
162
+
163
+ await it('renders every caption the .blp marks for translation', async () => {
164
+ const { root, unmount } = mountSharedTree(windowTree);
165
+ try {
166
+ const text = root.textContent ?? '';
167
+ const missing = markedCaptions(windowTree).filter((caption) => !text.includes(caption));
168
+ expect(missing.join(' | ')).toBe('');
169
+ } finally {
170
+ unmount();
171
+ }
172
+ });
173
+ });
174
+ };
@@ -15,11 +15,14 @@ import { describe, expect, it } from '@gjsify/unit';
15
15
 
16
16
  import type { AdwBottomSheet } from './elements/adw-bottom-sheet.js';
17
17
  import {
18
+ BOTTOM_SHEET_BOTTOM_BAR_VECTORS,
18
19
  BOTTOM_SHEET_CLOSE_VECTORS,
20
+ BOTTOM_SHEET_OPEN_VECTORS,
19
21
  BOTTOM_SHEET_PRESENTATION_VECTORS,
22
+ runBottomSheetBottomBarSteps,
20
23
  runBottomSheetSteps,
21
24
  } from '@gjsify/adwaita-core/conformance';
22
- import type { BottomSheetCloseOutcome } from '@gjsify/adwaita-core';
25
+ import type { BottomSheetChrome, BottomSheetCloseOutcome } from '@gjsify/adwaita-core';
23
26
 
24
27
  /** A mounted sheet with one child in each slot, ready to be driven. */
25
28
  function mountSheet(markup = ''): AdwBottomSheet {
@@ -44,6 +47,34 @@ function insideSheet(sheet: AdwBottomSheet): HTMLElement {
44
47
  return sheet.querySelector('.adw-bottom-sheet-sheet-body > button') as HTMLElement;
45
48
  }
46
49
 
50
+ function bottomBar(sheet: AdwBottomSheet): HTMLButtonElement {
51
+ return sheet.querySelector('.adw-bottom-sheet-bottom-bar') as HTMLButtonElement;
52
+ }
53
+
54
+ /** A bar child, the shape an app declares: one label inside the bin. */
55
+ function barLabel(text = 'Help'): HTMLElement {
56
+ const label = document.createElement('span');
57
+ label.textContent = text;
58
+ return label;
59
+ }
60
+
61
+ /**
62
+ * The chrome, read back off the REAL DOM rather than off the state that produced it.
63
+ *
64
+ * `surfaceVisible` has no element of its own: the sheet bin's on-screen-ness is a CSS
65
+ * transform here (`gtk_widget_set_child_visible` has no DOM counterpart that keeps the
66
+ * slide), and the two classes that drive that transform are what it is readable from.
67
+ */
68
+ function chromeOf(sheet: AdwBottomSheet): BottomSheetChrome {
69
+ const bar = bottomBar(sheet);
70
+ return {
71
+ layer: bar.hidden ? 'sheet' : 'bottom-bar',
72
+ surfaceVisible: sheet.classList.contains('open') || sheet.classList.contains('showing-bottom-bar'),
73
+ bottomBarInert: bar.classList.contains('inert'),
74
+ dimmed: dimming(sheet).classList.contains('visible'),
75
+ };
76
+ }
77
+
47
78
  /** Count the dismissal signals a sheet raises while `run` executes. */
48
79
  function recordSignals(sheet: AdwBottomSheet, run: () => void): { attempts: number; delegated: number } {
49
80
  let attempts = 0;
@@ -115,6 +146,140 @@ export const AdwBottomSheetTest = async () => {
115
146
  }
116
147
  });
117
148
 
149
+ await describe('adw-bottom-sheet open gate (libadwaita conformance vectors)', async () => {
150
+ for (const vector of BOTTOM_SHEET_OPEN_VECTORS) {
151
+ const { source, open, canOpen, hasBottomBar, revealBottomBar, outcome, rule } = vector;
152
+ const gates = `canOpen=${canOpen} · bar=${hasBottomBar} · revealed=${revealBottomBar}`;
153
+ const label = `${source} · open=${open} · ${gates}`;
154
+ await it(`${label} → ${outcome} — ${rule}`, () => {
155
+ const sheet = mountSheet();
156
+ sheet.setBottomBar(hasBottomBar ? barLabel() : null);
157
+ sheet.canOpen = canOpen;
158
+ sheet.revealBottomBar = revealBottomBar;
159
+ sheet.open = open;
160
+
161
+ expect(sheet.requestOpen(source)).toBe(outcome);
162
+ // Returning the right word and opening anyway is still wrong.
163
+ expect(sheet.open).toBe(outcome === 'open' ? true : open);
164
+ sheet.remove();
165
+ });
166
+ }
167
+ });
168
+
169
+ await describe('adw-bottom-sheet bottom bar (libadwaita conformance vectors)', async () => {
170
+ for (const vector of BOTTOM_SHEET_BOTTOM_BAR_VECTORS) {
171
+ await it(vector.rule, () => {
172
+ const sheet = mountSheet();
173
+ const notifications: boolean[] = [];
174
+ sheet.addEventListener('notify::open', (event) => {
175
+ notifications.push((event as CustomEvent).detail.open);
176
+ });
177
+
178
+ const outcomes = runBottomSheetBottomBarSteps(
179
+ {
180
+ setBottomBar: (present) => {
181
+ sheet.setBottomBar(present ? barLabel() : null);
182
+ },
183
+ setCanOpen: (canOpen) => {
184
+ sheet.canOpen = canOpen;
185
+ },
186
+ setRevealBottomBar: (reveal) => {
187
+ sheet.revealBottomBar = reveal;
188
+ },
189
+ setModal: (modal) => {
190
+ sheet.modal = modal;
191
+ },
192
+ setOpen: (open) => {
193
+ sheet.open = open;
194
+ },
195
+ requestOpen: (source) => sheet.requestOpen(source),
196
+ },
197
+ vector.steps,
198
+ );
199
+
200
+ expect(outcomes).toStrictEqual([...vector.outcomes]);
201
+ expect(notifications).toStrictEqual([...vector.notifications]);
202
+ expect(sheet.open).toBe(vector.open);
203
+ expect(chromeOf(sheet)).toStrictEqual({ ...vector.chrome });
204
+ sheet.remove();
205
+ });
206
+ }
207
+ });
208
+
209
+ await describe('adw-bottom-sheet bottom-bar affordance', async () => {
210
+ await it('a real click on the bar opens the sheet', () => {
211
+ // The vectors above call `requestOpen` directly, which proves the GATE and
212
+ // not the wiring: an element that never listens for the click passes every
213
+ // one of them. This is the event path an app actually gets.
214
+ const sheet = mountSheet();
215
+ sheet.setBottomBar(barLabel());
216
+ expect(sheet.open).toBe(false);
217
+ bottomBar(sheet).dispatchEvent(new MouseEvent('click', { bubbles: true }));
218
+ expect(sheet.open).toBe(true);
219
+ sheet.remove();
220
+ });
221
+
222
+ await it('declares the bar in markup, and the slot wrapper leaves nothing behind', () => {
223
+ const sheet = mountSheet(
224
+ '<adw-bottom-sheet-content><button id="under">Under</button></adw-bottom-sheet-content>' +
225
+ '<adw-bottom-sheet-bottom-bar><span id="bar">Help</span></adw-bottom-sheet-bottom-bar>' +
226
+ '<adw-bottom-sheet-sheet><button id="inside">Inside</button></adw-bottom-sheet-sheet>',
227
+ );
228
+ expect(sheet.querySelector('adw-bottom-sheet-bottom-bar')).toBe(null);
229
+ expect((sheet.bottomBar as HTMLElement).id).toBe('bar');
230
+ expect(chromeOf(sheet).layer).toBe('bottom-bar');
231
+ bottomBar(sheet).dispatchEvent(new MouseEvent('click', { bubbles: true }));
232
+ expect(sheet.open).toBe(true);
233
+ sheet.remove();
234
+ });
235
+
236
+ await it('keeps the inert bar enabled and focusable', () => {
237
+ // `can-open` off adds a style class and nothing else
238
+ // (adw-bottom-sheet.c:2033-2036). `disabled` would remove the focus stop and
239
+ // silence the click at the DOM instead of at the gate, which is a different
240
+ // widget: the bar is still the thing a screen reader announces.
241
+ const sheet = mountSheet();
242
+ sheet.setBottomBar(barLabel());
243
+ sheet.canOpen = false;
244
+ const bar = bottomBar(sheet);
245
+ expect(bar.disabled).toBe(false);
246
+ expect(bar.classList.contains('inert')).toBe(true);
247
+ bar.dispatchEvent(new MouseEvent('click', { bubbles: true }));
248
+ expect(sheet.open).toBe(false);
249
+ sheet.remove();
250
+ });
251
+
252
+ await it('shows the bar again after the sheet is dismissed', () => {
253
+ // The morph has to run BOTH ways. A port that switched the bin to the sheet
254
+ // page on open and never switched back leaves a sheet that can be opened
255
+ // exactly once, and every state assertion above still passes.
256
+ const sheet = mountSheet();
257
+ sheet.setBottomBar(barLabel());
258
+ bottomBar(sheet).dispatchEvent(new MouseEvent('click', { bubbles: true }));
259
+ expect(chromeOf(sheet).layer).toBe('sheet');
260
+ dimming(sheet).dispatchEvent(new MouseEvent('click', { bubbles: true }));
261
+ expect(sheet.open).toBe(false);
262
+ expect(chromeOf(sheet).layer).toBe('bottom-bar');
263
+ bottomBar(sheet).dispatchEvent(new MouseEvent('click', { bubbles: true }));
264
+ expect(sheet.open).toBe(true);
265
+ sheet.remove();
266
+ });
267
+
268
+ await it('a sheet with no bar has no way in but its owner', () => {
269
+ // REGRESSION PIN: this element shipped without a bottom bar at all, so an app
270
+ // whose only affordance is the bar had to reach past the widget and write
271
+ // `open` itself. Nothing on screen opens this one.
272
+ const sheet = mountSheet();
273
+ expect(bottomBar(sheet).hidden).toBe(true);
274
+ expect(sheet.requestOpen('bottom-bar')).toBe('ignored');
275
+ expect(sheet.requestOpen('swipe')).toBe('ignored');
276
+ expect(sheet.open).toBe(false);
277
+ sheet.open = true;
278
+ expect(sheet.open).toBe(true);
279
+ sheet.remove();
280
+ });
281
+ });
282
+
118
283
  await describe('adw-bottom-sheet open attribute semantics', async () => {
119
284
  await it('open="false" renders CLOSED', () => {
120
285
  // `hasAttribute('open')` for the getter while every other boolean uses
@@ -503,12 +503,15 @@ export const AdwChromeTest = async () => {
503
503
  });
504
504
 
505
505
  await it('centres a status page in the pane rather than against its top bar', async () => {
506
- const host = mountPreview(STATUS_PANE_PREVIEW);
506
+ // 400px, not the preview's 340: at upstream's metrics this page needs 298px, and
507
+ // in the 293px a 340px view leaves under its bar GTK would scroll it rather than
508
+ // centre it.
509
+ const host = mountPreview(STATUS_PANE_PREVIEW, 400);
507
510
  await settle();
508
511
  const area = host.querySelector('.adw-toolbar-view-content') as HTMLElement;
509
512
  const status = host.querySelector('adw-status-page') as HTMLElement;
510
513
 
511
- expect(heightOf(area) > 0 && heightOf(area) < 340).toBe(true);
514
+ expect(heightOf(area) > 0 && heightOf(area) < 400).toBe(true);
512
515
  expect(heightOf(status)).toBe(heightOf(area));
513
516
 
514
517
  // What the reader actually sees, and the discriminator at the same time: