@celestia-island/hikari 0.41.3 → 0.41.6
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 +1 -1
- package/src/components/HkButton.scss +0 -56
- package/src/components/HkButton.test.tsx +0 -81
- package/src/components/HkButton.tsx +28 -42
- package/src/components/HkIconButton.test.tsx +0 -15
- package/src/components/HkIconButton.tsx +5 -18
- package/src/components/HkSelectionGrid.scss +9 -0
- package/src/components/HkSelectionGrid.test.tsx +119 -0
- package/src/components/HkSelectionGrid.tsx +12 -1
- package/src/runtime/createAuthGuard.permissions.test.ts +141 -0
- package/src/runtime/createAuthGuard.test.ts +128 -0
- package/src/runtime/createAuthGuard.ts +171 -0
- package/src/runtime/historySafetyNet.test.ts +139 -0
- package/src/runtime/historySafetyNet.ts +145 -0
- package/src/runtime/index.ts +14 -0
- package/src/runtime/navigationGuard.test.ts +107 -0
- package/src/runtime/navigationGuard.ts +62 -0
package/package.json
CHANGED
|
@@ -64,62 +64,6 @@
|
|
|
64
64
|
min-height: 2.75rem;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
/* ---- Prefix / suffix icon boxes ---- */
|
|
68
|
-
/* Standard-part contract (2026-09-10 user direction): a button glyph
|
|
69
|
-
renders inside a FIXED SQUARE box — flex:none so host flex rows can
|
|
70
|
-
never squeeze it into a squat sliver, centered on both axes so the
|
|
71
|
-
icon rides the button's vertical midline at any button height, and
|
|
72
|
-
the box (not the bare svg) owns the rhythm so the glyph keeps a
|
|
73
|
-
consistent margin from the adjacent label via the button's own gap.
|
|
74
|
-
The 1rem box matches the 16px glyph HkButton renders for the
|
|
75
|
-
icon/suffix props. */
|
|
76
|
-
.hk-btn-icon,
|
|
77
|
-
.hk-btn-suffix {
|
|
78
|
-
display: inline-flex;
|
|
79
|
-
align-items: center;
|
|
80
|
-
justify-content: center;
|
|
81
|
-
flex: none;
|
|
82
|
-
inline-size: 1rem;
|
|
83
|
-
block-size: 1rem;
|
|
84
|
-
line-height: 0;
|
|
85
|
-
|
|
86
|
-
svg {
|
|
87
|
-
display: block;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/* ---- Icon-only square ---- */
|
|
92
|
-
/* An icon-only HkButton (glyph, no label) occupies a SQUARE footprint
|
|
93
|
-
by default: padding collapses fully and width locks to the size
|
|
94
|
-
variant's min-height, so it reads as the same standard square the
|
|
95
|
-
HIconButton family guarantees. box-sizing is self-declared (house
|
|
96
|
-
convention — geometry rules never rely on a host-side reset reaching
|
|
97
|
-
the component): with zero padding the min-height governs the height
|
|
98
|
-
on every size, an exact square under border-box AND content-box.
|
|
99
|
-
Hosts may still override with an explicit width when a design
|
|
100
|
-
genuinely calls for another shape. */
|
|
101
|
-
.hk-btn-icon-only {
|
|
102
|
-
padding: 0;
|
|
103
|
-
box-sizing: border-box;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
.hk-btn-sm.hk-btn-icon-only {
|
|
107
|
-
width: 1.75rem;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
.hk-btn-md.hk-btn-icon-only {
|
|
111
|
-
width: 2.5rem;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
.hk-btn-lg.hk-btn-icon-only {
|
|
115
|
-
width: 2.75rem;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/* The first-class block prop wins over the square width. */
|
|
119
|
-
.hk-btn-block.hk-btn-icon-only {
|
|
120
|
-
width: 100%;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
67
|
/* ---- Primary ---- */
|
|
124
68
|
|
|
125
69
|
.hk-btn-primary {
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "vitest";
|
|
2
2
|
import { createApp, h } from "vue";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
3
|
|
|
7
4
|
import HkButton from "./HkButton";
|
|
8
5
|
|
|
9
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
10
|
-
|
|
11
6
|
/**
|
|
12
7
|
* HkButton contract tests:
|
|
13
8
|
* - variant / size / block / loading class mapping on the root <button>
|
|
@@ -147,82 +142,6 @@ describe("HkButton content slots", () => {
|
|
|
147
142
|
});
|
|
148
143
|
});
|
|
149
144
|
|
|
150
|
-
describe("HkButton icon-only square contract", () => {
|
|
151
|
-
it("flags the icon-only square class for a glyph with no label", () => {
|
|
152
|
-
const iconOnly = mount(h(HkButton, { icon: "close", ariaLabel: "Close" }));
|
|
153
|
-
const btn = button(iconOnly);
|
|
154
|
-
expect(btn.className).toContain("hk-btn-icon-only");
|
|
155
|
-
expect(iconOnly.querySelector(".hk-btn-icon")).not.toBeNull();
|
|
156
|
-
|
|
157
|
-
const suffixOnly = mount(h(HkButton, { suffix: "close", ariaLabel: "Next" }));
|
|
158
|
-
expect(button(suffixOnly).className).toContain("hk-btn-icon-only");
|
|
159
|
-
|
|
160
|
-
// Glyph + text stays a normal text button — no square collapse.
|
|
161
|
-
const withText = mount(h(HkButton, { icon: "close" }, () => "Close"));
|
|
162
|
-
expect(button(withText).className).not.toContain("hk-btn-icon-only");
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it("keeps the square footprint while an icon-only button loads", () => {
|
|
166
|
-
const c = mount(h(HkButton, { icon: "close", loading: true, ariaLabel: "Busy" }));
|
|
167
|
-
// The spinner replaces the glyph, but the button still carries no
|
|
168
|
-
// label — the square footprint must hold through the swap.
|
|
169
|
-
expect(button(c).className).toContain("hk-btn-icon-only");
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("keeps the block class alongside the icon-only square class", () => {
|
|
173
|
-
// CSS precedence lives in the SCSS contract test below; this pins the
|
|
174
|
-
// class composition so both rules can actually apply.
|
|
175
|
-
const c = mount(h(HkButton, { icon: "close", block: true, ariaLabel: "Go" }));
|
|
176
|
-
const cls = button(c).className;
|
|
177
|
-
expect(cls).toContain("hk-btn-block");
|
|
178
|
-
expect(cls).toContain("hk-btn-icon-only");
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("does not square-collapse when a shortcut chip rides along", () => {
|
|
182
|
-
// The HKbd chip is an extra flex child — the fixed square width would
|
|
183
|
-
// visibly clip it, so icon + shortcut stays a padded button.
|
|
184
|
-
const c = mount(h(HkButton, { icon: "close", shortcut: "Ctrl+W", ariaLabel: "Close" }));
|
|
185
|
-
expect(button(c).className).not.toContain("hk-btn-icon-only");
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
// SCSS geometry contract (house pattern, cf. HkNumberInput.affix.test.ts):
|
|
190
|
-
// the square is a CSS fact — padding fully collapsed + self-declared
|
|
191
|
-
// border-box (survives hosts without a reset) + the block prop winning
|
|
192
|
-
// over the square width. Textual assertions because happy-dom does not
|
|
193
|
-
// lay out.
|
|
194
|
-
describe("HkButton icon-only SCSS contract", () => {
|
|
195
|
-
const scss = readFileSync(join(here, "HkButton.scss"), "utf-8");
|
|
196
|
-
|
|
197
|
-
it("self-declares the collapsed square geometry", () => {
|
|
198
|
-
const rule = scss.match(/\.hk-btn-icon-only\s*{[^}]*}/)?.[0] ?? "";
|
|
199
|
-
expect(rule, "icon-only rule must exist").toBeTruthy();
|
|
200
|
-
expect(rule).toContain("padding: 0");
|
|
201
|
-
expect(rule).toContain("box-sizing: border-box");
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it("lets the block prop win over the square width", () => {
|
|
205
|
-
const rule = scss.match(/\.hk-btn-block\.hk-btn-icon-only\s*{[^}]*}/)?.[0] ?? "";
|
|
206
|
-
expect(rule, "block override rule must exist").toBeTruthy();
|
|
207
|
-
expect(rule).toContain("width: 100%");
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it("locks each size variant width to its min-height", () => {
|
|
211
|
-
const cases: Array<[string, string]> = [
|
|
212
|
-
["sm", "1.75rem"],
|
|
213
|
-
["md", "2.5rem"],
|
|
214
|
-
["lg", "2.75rem"],
|
|
215
|
-
];
|
|
216
|
-
for (const [size, width] of cases) {
|
|
217
|
-
const rule = scss.match(new RegExp(`\\.hk-btn-${size}\\.hk-btn-icon-only\\s*{[^}]*}`))?.[0] ?? "";
|
|
218
|
-
expect(rule, `${size} icon-only rule must exist`).toBeTruthy();
|
|
219
|
-
expect(rule).toContain(`width: ${width}`);
|
|
220
|
-
const minH = scss.match(new RegExp(`\\.hk-btn-${size}\\s*{[^}]*}`))?.[0] ?? "";
|
|
221
|
-
expect(minH).toContain(`min-height: ${width}`);
|
|
222
|
-
}
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
|
|
226
145
|
describe("HkButton attr fallthrough (inheritAttrs: false)", () => {
|
|
227
146
|
it("spreads extra attrs and honors an explicit type", () => {
|
|
228
147
|
const c = mount(h(HkButton, { type: "submit", "data-test": "probe" }, () => "Save"));
|
|
@@ -42,47 +42,33 @@ export default defineComponent({
|
|
|
42
42
|
props.shortcut ? "hk-btn-has-shortcut" : "",
|
|
43
43
|
]);
|
|
44
44
|
|
|
45
|
-
return () =>
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
{props.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
</span>
|
|
74
|
-
) : null}
|
|
75
|
-
{slots.default?.()}
|
|
76
|
-
{props.suffix ? (
|
|
77
|
-
<span class="hk-btn-suffix">
|
|
78
|
-
<HIcon name={props.suffix} size={16} />
|
|
79
|
-
</span>
|
|
80
|
-
) : null}
|
|
81
|
-
{props.shortcut ? (
|
|
82
|
-
<HKbd keys={props.shortcut!} size="sm" />
|
|
83
|
-
) : null}
|
|
84
|
-
</button>
|
|
85
|
-
);
|
|
86
|
-
};
|
|
45
|
+
return () => (
|
|
46
|
+
<button
|
|
47
|
+
{...attrs}
|
|
48
|
+
type={(attrs.type as "button" | "submit" | "reset") || "button"}
|
|
49
|
+
disabled={props.disabled || props.loading}
|
|
50
|
+
class={[buttonClass.value, attrs.class]}
|
|
51
|
+
style={attrs.style || undefined}
|
|
52
|
+
aria-label={props.ariaLabel}
|
|
53
|
+
aria-busy={props.loading || undefined}
|
|
54
|
+
onClick={(e) => emit("click", e)}
|
|
55
|
+
>
|
|
56
|
+
{props.loading ? <HSpinner size="xs" tone="current" /> : null}
|
|
57
|
+
{!props.loading && props.icon ? (
|
|
58
|
+
<span class="hk-btn-icon">
|
|
59
|
+
<HIcon name={props.icon} size={16} />
|
|
60
|
+
</span>
|
|
61
|
+
) : null}
|
|
62
|
+
{slots.default?.()}
|
|
63
|
+
{props.suffix ? (
|
|
64
|
+
<span class="hk-btn-suffix">
|
|
65
|
+
<HIcon name={props.suffix} size={16} />
|
|
66
|
+
</span>
|
|
67
|
+
) : null}
|
|
68
|
+
{props.shortcut ? (
|
|
69
|
+
<HKbd keys={props.shortcut!} size="sm" />
|
|
70
|
+
) : null}
|
|
71
|
+
</button>
|
|
72
|
+
);
|
|
87
73
|
},
|
|
88
74
|
});
|
|
@@ -102,19 +102,4 @@ describe("HkIconButton", () => {
|
|
|
102
102
|
const c = mountComp(() => h(HkIconButton, { "aria-label": "probe" }));
|
|
103
103
|
expect(c.querySelector("circle")).not.toBeNull();
|
|
104
104
|
});
|
|
105
|
-
|
|
106
|
-
// Named-icon contract (2026-09-10): the icon prop resolves through HIcon
|
|
107
|
-
// (functional keys ride the host material pack) — slot content still
|
|
108
|
-
// wins over it, and the placeholder circle only shows with neither.
|
|
109
|
-
it("renders the icon prop through HIcon, below slot precedence", () => {
|
|
110
|
-
const propOnly = mountComp(() => h(HkIconButton, { icon: "close", "aria-label": "close" }));
|
|
111
|
-
expect(propOnly.querySelector(".hk-icon")).not.toBeNull();
|
|
112
|
-
expect(propOnly.querySelector("circle")).toBeNull();
|
|
113
|
-
|
|
114
|
-
const propAndSlot = mountComp(() =>
|
|
115
|
-
h(HkIconButton, { icon: "close", "aria-label": "close" }, { icon: () => h("span", { class: "slot-probe" }) }),
|
|
116
|
-
);
|
|
117
|
-
expect(propAndSlot.querySelector(".slot-probe")).not.toBeNull();
|
|
118
|
-
expect(propAndSlot.querySelector(".hk-icon")).toBeNull();
|
|
119
|
-
});
|
|
120
105
|
});
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { computed, defineComponent, type PropType } from "vue";
|
|
2
2
|
import "./HkIconButton.scss";
|
|
3
3
|
import "./HkIconButtonVars.scss";
|
|
4
|
-
import HIcon from "./HkIcon";
|
|
5
4
|
|
|
6
5
|
export default defineComponent({
|
|
7
6
|
name: "HkIconButton",
|
|
@@ -22,14 +21,6 @@ export default defineComponent({
|
|
|
22
21
|
`hk-icon-button-${props.variant}`,
|
|
23
22
|
]);
|
|
24
23
|
|
|
25
|
-
// Named-icon contract (2026-09-10 user direction): the icon prop is a
|
|
26
|
-
// first-class way to draw the glyph — it resolves through HIcon so
|
|
27
|
-
// functional keys ("close", "back") keep riding the host material-pack
|
|
28
|
-
// family exactly like the slot composition does. The rendered size is
|
|
29
|
-
// pinned by --hi-icon-button-icon-size; the HIcon size prop only picks
|
|
30
|
-
// the matching box class. Slot content still wins over the prop.
|
|
31
|
-
const glyphSize = computed(() => (props.size >= 32 ? 20 : 16));
|
|
32
|
-
|
|
33
24
|
return () => (
|
|
34
25
|
<button
|
|
35
26
|
class={cls.value}
|
|
@@ -44,15 +35,11 @@ export default defineComponent({
|
|
|
44
35
|
? // Natural children usage: <HIconButton><HIcon .../></HIconButton>
|
|
45
36
|
// renders the child as the icon (unified window-close wave).
|
|
46
37
|
slots.default()
|
|
47
|
-
:
|
|
48
|
-
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
53
|
-
<circle cx="12" cy="12" r="10" />
|
|
54
|
-
</svg>
|
|
55
|
-
)}
|
|
38
|
+
: (
|
|
39
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
40
|
+
<circle cx="12" cy="12" r="10" />
|
|
41
|
+
</svg>
|
|
42
|
+
)}
|
|
56
43
|
</span>
|
|
57
44
|
</button>
|
|
58
45
|
);
|
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
margin: 0 0 0.5rem;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/* Foot note under the grid — same register as .hk-input-hint so the
|
|
15
|
+
helper line reads identically wherever it appears in a form. */
|
|
16
|
+
.hk-selection-grid-hint {
|
|
17
|
+
margin: var(--space-4, 0.25rem) 0 0;
|
|
18
|
+
font-size: var(--text-xs, 0.75rem);
|
|
19
|
+
color: color-mix(in srgb, rgb(var(--color-muted)) 70%, rgb(var(--color-background)));
|
|
20
|
+
text-align: start;
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
.hk-selection-grid-grid {
|
|
15
24
|
display: grid;
|
|
16
25
|
gap: 0.5rem;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkSelectionGrid, { type SelectionGridItem } from "./HkSelectionGrid";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkSelectionGrid contract tests:
|
|
8
|
+
* - single mode keys the lit state off `selectedId` and lights exactly one card
|
|
9
|
+
* - multi mode keys it off `selectedIds` membership and lights every listed id
|
|
10
|
+
* - multi mode never lets `selectedId` leak through (the two selectors stay
|
|
11
|
+
* independent, so a stale single id cannot double-light a card)
|
|
12
|
+
* - `select` fires for already-selected cards too — the consumer owns the
|
|
13
|
+
* toggle, the grid only reports the click
|
|
14
|
+
* - the optional `hint` renders under the grid and is absent when unset
|
|
15
|
+
*
|
|
16
|
+
* House style: raw createApp mounts on a shared container list torn down
|
|
17
|
+
* after each case; DOM assertions via document queries.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
21
|
+
|
|
22
|
+
function mount(node: ReturnType<typeof h>) {
|
|
23
|
+
const container = document.createElement("div");
|
|
24
|
+
document.body.appendChild(container);
|
|
25
|
+
const app = createApp({ render: () => node });
|
|
26
|
+
app.mount(container);
|
|
27
|
+
mounts.push({ app, container });
|
|
28
|
+
return container;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
33
|
+
app.unmount();
|
|
34
|
+
container.remove();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const ITEMS: SelectionGridItem[] = [
|
|
39
|
+
{ id: "text", title: "Text" },
|
|
40
|
+
{ id: "vision", title: "Vision" },
|
|
41
|
+
{ id: "gen", title: "Generation" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function cards(container: HTMLElement) {
|
|
45
|
+
return Array.from(container.querySelectorAll<HTMLElement>(".hk-selection-grid-item"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function litIds(container: HTMLElement) {
|
|
49
|
+
return cards(container)
|
|
50
|
+
.filter((el) => el.hasAttribute("data-selected"))
|
|
51
|
+
.map((el) => el.querySelector(".hk-selection-grid-name")?.textContent);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("HkSelectionGrid single mode", () => {
|
|
55
|
+
it("lights only the card matching selectedId", () => {
|
|
56
|
+
const container = mount(h(HkSelectionGrid, { items: ITEMS, selectedId: "vision" }));
|
|
57
|
+
expect(litIds(container)).toEqual(["Vision"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("lights nothing when selectedId is undefined", () => {
|
|
61
|
+
const container = mount(h(HkSelectionGrid, { items: ITEMS }));
|
|
62
|
+
expect(litIds(container)).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("ignores selectedIds unless multi is on", () => {
|
|
66
|
+
const container = mount(
|
|
67
|
+
h(HkSelectionGrid, { items: ITEMS, selectedIds: ["text", "gen"], selectedId: "vision" }),
|
|
68
|
+
);
|
|
69
|
+
expect(litIds(container)).toEqual(["Vision"]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("HkSelectionGrid multi mode", () => {
|
|
74
|
+
it("lights every id listed in selectedIds", () => {
|
|
75
|
+
const container = mount(
|
|
76
|
+
h(HkSelectionGrid, { items: ITEMS, multi: true, selectedIds: ["text", "gen"] }),
|
|
77
|
+
);
|
|
78
|
+
expect(litIds(container)).toEqual(["Text", "Generation"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("lights nothing when selectedIds is empty", () => {
|
|
82
|
+
const container = mount(h(HkSelectionGrid, { items: ITEMS, multi: true }));
|
|
83
|
+
expect(litIds(container)).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("does not read selectedId while multi is on", () => {
|
|
87
|
+
const container = mount(
|
|
88
|
+
h(HkSelectionGrid, { items: ITEMS, multi: true, selectedIds: ["gen"], selectedId: "text" }),
|
|
89
|
+
);
|
|
90
|
+
expect(litIds(container)).toEqual(["Generation"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("emits select for an already-selected card so the consumer can toggle it off", () => {
|
|
94
|
+
const seen: string[] = [];
|
|
95
|
+
const container = mount(
|
|
96
|
+
h(HkSelectionGrid, {
|
|
97
|
+
items: ITEMS,
|
|
98
|
+
multi: true,
|
|
99
|
+
selectedIds: ["vision"],
|
|
100
|
+
onSelect: (item: SelectionGridItem) => seen.push(item.id),
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
cards(container)[1]!.click();
|
|
104
|
+
expect(seen).toEqual(["vision"]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("HkSelectionGrid hint", () => {
|
|
109
|
+
it("renders the hint line under the grid when provided", () => {
|
|
110
|
+
const container = mount(h(HkSelectionGrid, { items: ITEMS, hint: "Pick one or more" }));
|
|
111
|
+
const hint = container.querySelector(".hk-selection-grid-hint");
|
|
112
|
+
expect(hint?.textContent).toBe("Pick one or more");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("renders no hint node when the prop is unset", () => {
|
|
116
|
+
const container = mount(h(HkSelectionGrid, { items: ITEMS }));
|
|
117
|
+
expect(container.querySelector(".hk-selection-grid-hint")).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -19,8 +19,14 @@ export default defineComponent({
|
|
|
19
19
|
props: {
|
|
20
20
|
items: { type: Array as PropType<SelectionGridItem[]>, required: true },
|
|
21
21
|
selectedId: { type: String, default: undefined },
|
|
22
|
+
selectedIds: {
|
|
23
|
+
type: Array as PropType<string[]>,
|
|
24
|
+
default: () => [],
|
|
25
|
+
},
|
|
26
|
+
multi: { type: Boolean, default: false },
|
|
22
27
|
columns: { type: Number as PropType<SelectionGridCols>, default: 2 },
|
|
23
28
|
groupTitle: { type: String, default: undefined },
|
|
29
|
+
hint: { type: String, default: undefined },
|
|
24
30
|
dense: { type: Boolean, default: false },
|
|
25
31
|
},
|
|
26
32
|
emits: {
|
|
@@ -41,7 +47,9 @@ export default defineComponent({
|
|
|
41
47
|
data-dense={props.dense || undefined}
|
|
42
48
|
>
|
|
43
49
|
{props.items.map((item) => {
|
|
44
|
-
const isSelected = props.
|
|
50
|
+
const isSelected = props.multi
|
|
51
|
+
? props.selectedIds.includes(item.id)
|
|
52
|
+
: props.selectedId === item.id;
|
|
45
53
|
const ItemIcon = item.icon;
|
|
46
54
|
|
|
47
55
|
return (
|
|
@@ -93,6 +101,9 @@ export default defineComponent({
|
|
|
93
101
|
);
|
|
94
102
|
})}
|
|
95
103
|
</div>
|
|
104
|
+
{props.hint && (
|
|
105
|
+
<p class="hk-selection-grid-hint">{props.hint}</p>
|
|
106
|
+
)}
|
|
96
107
|
</div>
|
|
97
108
|
);
|
|
98
109
|
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createAuthGuard } from "./createAuthGuard";
|
|
4
|
+
|
|
5
|
+
/** Shape the guard's router parameter accepts (a slim slice of
|
|
6
|
+
* vue-router's Router — the guard only calls beforeEach/onError). */
|
|
7
|
+
interface SlimRouter {
|
|
8
|
+
beforeEach: (fn: (to: GuardTo) => unknown) => void;
|
|
9
|
+
onError: (fn: (error: unknown, to: GuardTo) => void) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface GuardTo {
|
|
13
|
+
name?: string | symbol | null;
|
|
14
|
+
fullPath?: string;
|
|
15
|
+
meta?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Minimal router double: records guards, lets tests drive them. */
|
|
19
|
+
function makeRouter() {
|
|
20
|
+
const guards: Array<(to: GuardTo) => unknown> = [];
|
|
21
|
+
const router: SlimRouter = {
|
|
22
|
+
beforeEach: (fn) => { guards.push(fn); },
|
|
23
|
+
onError: () => {},
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
router,
|
|
27
|
+
run: (to: GuardTo) => {
|
|
28
|
+
expect(guards.length).toBeGreaterThan(0);
|
|
29
|
+
return guards[guards.length - 1](to);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeAuthStore(overrides: Partial<Record<string, unknown>> = {}) {
|
|
35
|
+
return {
|
|
36
|
+
isAuthenticated: false,
|
|
37
|
+
user: null,
|
|
38
|
+
tryRestoreSession: vi.fn().mockResolvedValue(undefined),
|
|
39
|
+
checkSetup: vi.fn().mockResolvedValue({ needs_setup: false }),
|
|
40
|
+
fetchUser: vi.fn().mockResolvedValue(undefined),
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Permissions-store double mirroring the real store's semantics:
|
|
46
|
+
* `fetch()` resolves the set and flips loaded=true; a failing fetch
|
|
47
|
+
* leaves loaded=false (fail-open). */
|
|
48
|
+
function makePermStore(init: { loaded?: boolean; perms?: string[]; failFetch?: boolean } = {}) {
|
|
49
|
+
const state = { loaded: init.loaded ?? false, perms: init.perms ?? [] };
|
|
50
|
+
return {
|
|
51
|
+
get loaded() { return state.loaded; },
|
|
52
|
+
fetch: vi.fn().mockImplementation(async () => {
|
|
53
|
+
if (init.failFetch) return;
|
|
54
|
+
state.perms = init.perms ?? [];
|
|
55
|
+
state.loaded = true;
|
|
56
|
+
}),
|
|
57
|
+
has: (perm: string) => state.perms.includes(perm),
|
|
58
|
+
hasAny: (perms: string[]) => perms.some((p) => state.perms.includes(p)),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const baseOpts = {
|
|
63
|
+
loginRoute: "login",
|
|
64
|
+
homeRoute: "demiurge",
|
|
65
|
+
setupRoute: "setup-redirect",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
describe("createAuthGuard requiresPermission enforcement", () => {
|
|
69
|
+
// Contract: routes declare `requiresPermission` (e.g. /backend with
|
|
70
|
+
// "system.read") and the guard is the enforcement point. The meta used
|
|
71
|
+
// to be declared but never consumed — any authenticated user could
|
|
72
|
+
// open the admin console route and only AdminLayout's own nav filtering
|
|
73
|
+
// hid the views (field report 2026-09).
|
|
74
|
+
it("bounces a user lacking the required permission to the home route", async () => {
|
|
75
|
+
const auth = makeAuthStore({ isAuthenticated: true, user: { username: "momoi" } });
|
|
76
|
+
const perms = makePermStore({ perms: [] });
|
|
77
|
+
const { router, run } = makeRouter();
|
|
78
|
+
createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth, permissions: () => perms });
|
|
79
|
+
|
|
80
|
+
const verdict = await run({
|
|
81
|
+
name: "admin",
|
|
82
|
+
fullPath: "/backend",
|
|
83
|
+
meta: { requiresAuth: true, requiresPermission: "system.read" },
|
|
84
|
+
});
|
|
85
|
+
expect(verdict).toEqual({ name: "demiurge" });
|
|
86
|
+
expect(perms.fetch).toHaveBeenCalledOnce();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("admits a user holding the required permission", async () => {
|
|
90
|
+
const auth = makeAuthStore({ isAuthenticated: true, user: { username: "demiurge" } });
|
|
91
|
+
const perms = makePermStore({ loaded: true, perms: ["system.read"] });
|
|
92
|
+
const { router, run } = makeRouter();
|
|
93
|
+
createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth, permissions: () => perms });
|
|
94
|
+
|
|
95
|
+
const verdict = await run({
|
|
96
|
+
name: "admin",
|
|
97
|
+
fullPath: "/backend",
|
|
98
|
+
meta: { requiresAuth: true, requiresPermission: "system.read" },
|
|
99
|
+
});
|
|
100
|
+
expect(verdict).toBeUndefined();
|
|
101
|
+
// Already-loaded set must not be refetched on every navigation.
|
|
102
|
+
expect(perms.fetch).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("accepts any-of lists via hasAny", async () => {
|
|
106
|
+
const auth = makeAuthStore({ isAuthenticated: true, user: { username: "u" } });
|
|
107
|
+
const perms = makePermStore({ loaded: true, perms: ["industrial.write"] });
|
|
108
|
+
const { router, run } = makeRouter();
|
|
109
|
+
createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth, permissions: () => perms });
|
|
110
|
+
|
|
111
|
+
const verdict = await run({
|
|
112
|
+
name: "writer",
|
|
113
|
+
fullPath: "/somewhere",
|
|
114
|
+
meta: { requiresPermission: ["system.read", "industrial.write"] },
|
|
115
|
+
});
|
|
116
|
+
expect(verdict).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("fails open while the permission set is unloaded (store outage must not brick navigation)", async () => {
|
|
120
|
+
const auth = makeAuthStore({ isAuthenticated: true, user: { username: "u" } });
|
|
121
|
+
const perms = makePermStore({ failFetch: true });
|
|
122
|
+
const { router, run } = makeRouter();
|
|
123
|
+
createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth, permissions: () => perms });
|
|
124
|
+
|
|
125
|
+
const verdict = await run({
|
|
126
|
+
name: "admin",
|
|
127
|
+
fullPath: "/backend",
|
|
128
|
+
meta: { requiresAuth: true, requiresPermission: "system.read" },
|
|
129
|
+
});
|
|
130
|
+
expect(verdict).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("ignores routes without the meta and unwired permission stores", async () => {
|
|
134
|
+
const auth = makeAuthStore({ isAuthenticated: true, user: { username: "u" } });
|
|
135
|
+
const { router, run } = makeRouter();
|
|
136
|
+
createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth });
|
|
137
|
+
|
|
138
|
+
const verdict = await run({ name: "demiurge", fullPath: "/@ws", meta: { requiresAuth: true } });
|
|
139
|
+
expect(verdict).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
});
|