@celestia-island/hikari 0.55.52 → 0.55.53

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.52",
3
+ "version": "0.55.53",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -0,0 +1,169 @@
1
+ // HkSettingsDialog — the sectioned settings shell (rail + pane) and its
2
+ // content vocabulary (group / sub / hint). Grammar extracted from the
3
+ // wowsp settings window (the pattern this component upstreams): values
4
+ // are the L2 scale tokens wowsp already used, so existing surfaces
5
+ // migrate without repainting.
6
+ //
7
+ // Scroll ownership: inside the dialog (host class `hk-settings-host`)
8
+ // the shell gets a STABLE height sized under the modal body scroller's
9
+ // inner padding, so HkModal's floating overlay scrollbar stays idle and
10
+ // the pane's own edge bar is the only scrollbar. The negative-margin
11
+ // trick puts that edge — and its scrollbar — flush with the modal
12
+ // window's right edge; the padding re-applies the content inset between
13
+ // the scrollbar and the cards. A page-embedded body (no host class)
14
+ // skips both: plain scroll container, host-managed layout.
15
+
16
+ .hk-settings {
17
+ display: flex;
18
+ align-items: stretch;
19
+ gap: var(--space-20);
20
+
21
+ /* ── section rail (mirrors the main sidebar nav look) ── */
22
+ &__rail {
23
+ flex: none;
24
+ width: 10.5rem;
25
+ display: flex;
26
+ flex-direction: column;
27
+ gap: var(--space-2);
28
+ padding: var(--space-4) var(--space-12) var(--space-4) 0;
29
+ border-right: 1px solid rgb(var(--color-border) / 15%);
30
+ overflow-y: auto;
31
+ }
32
+
33
+ &__rail-item {
34
+ display: flex;
35
+ align-items: center;
36
+ gap: var(--space-10);
37
+ padding: var(--space-8) var(--space-12);
38
+ border: 0;
39
+ border-radius: var(--radius-sm);
40
+ background: transparent;
41
+ text-align: left;
42
+ color: rgb(var(--color-muted));
43
+ font-size: var(--text-sm);
44
+ font-family: inherit;
45
+ cursor: pointer;
46
+ transition:
47
+ color var(--duration-short) var(--ease-standard),
48
+ background var(--duration-short) var(--ease-standard);
49
+
50
+ &:hover:not(:disabled) {
51
+ color: rgb(var(--color-text));
52
+ background: rgb(var(--color-primary) / 8%);
53
+ }
54
+
55
+ &:focus-visible {
56
+ outline: 2px solid rgb(var(--color-primary));
57
+ outline-offset: 1px;
58
+ }
59
+
60
+ &:disabled {
61
+ opacity: 0.45;
62
+ cursor: not-allowed;
63
+ }
64
+
65
+ &.is-active {
66
+ color: rgb(var(--color-primary));
67
+ background: rgb(var(--color-primary) / 12%);
68
+ font-weight: 500;
69
+ }
70
+ }
71
+
72
+ &__rail-icon {
73
+ flex-shrink: 0;
74
+ display: flex;
75
+ align-items: center;
76
+ justify-content: center;
77
+ width: var(--space-20);
78
+ height: var(--space-20);
79
+ }
80
+
81
+ &__rail-label {
82
+ min-width: 0;
83
+ overflow: hidden;
84
+ text-overflow: ellipsis;
85
+ white-space: nowrap;
86
+ }
87
+
88
+ /* ── content pane: one section at a time, this is the scroller ── */
89
+ &__pane {
90
+ flex: 1;
91
+ min-width: 0;
92
+ display: flex;
93
+ flex-direction: column;
94
+ gap: var(--space-16);
95
+ overflow-y: auto;
96
+ }
97
+ }
98
+
99
+ /* ── Dialog host: stable frame + flush scrollbar edge ────────────────── */
100
+
101
+ /* Give the body inner a definite height so the shell's fit is computed
102
+ against the real scroll area (wowsp's host rule, verbatim). */
103
+ .hk-settings-host .hk-modal-body-inner {
104
+ height: 100%;
105
+ }
106
+
107
+ .hk-settings-host .hk-settings {
108
+ height: var(--hk-settings-height, calc(70vh - 6.5rem));
109
+ }
110
+
111
+ /* Cancel the modal body's fixed right padding so the pane's scrollbar
112
+ sits flush at the window edge; re-apply the inset between the bar and
113
+ the cards. HkModal retunes the body padding to 1rem under 768px —
114
+ follow it or the pane overshoots the edge. */
115
+ .hk-settings-host .hk-settings__pane {
116
+ margin-right: calc(-1 * var(--hk-modal-padding-body, 1.5rem));
117
+ padding-right: var(--space-28);
118
+
119
+ @media (max-width: 767px) {
120
+ margin-right: -1rem;
121
+ padding-right: var(--space-20);
122
+ }
123
+ }
124
+
125
+ /* ── Content vocabulary ───────────────────────────────────────────────── */
126
+
127
+ /* One titled card per settings area. Slightly translucent so the cards
128
+ sit quietly on image wallpapers. */
129
+ .hk-settings-group {
130
+ display: flex;
131
+ flex-direction: column;
132
+ gap: var(--space-12);
133
+ padding: var(--space-16) var(--space-20);
134
+ background: rgb(var(--color-surface) / 55%);
135
+ border: 1px solid rgb(var(--color-border) / 15%);
136
+ border-radius: var(--radius-md);
137
+ }
138
+
139
+ .hk-settings-group-title {
140
+ margin: 0;
141
+ font-size: var(--text-sm);
142
+ font-weight: 600;
143
+ color: rgb(var(--color-text));
144
+ }
145
+
146
+ /* Titled sub-block inside a group. */
147
+ .hk-settings-sub {
148
+ display: flex;
149
+ flex-direction: column;
150
+ gap: var(--space-8);
151
+ margin-top: var(--space-4);
152
+ }
153
+
154
+ .hk-settings-sub-title {
155
+ margin: 0;
156
+ font-size: var(--text-xs);
157
+ font-weight: 600;
158
+ color: rgb(var(--color-muted));
159
+ text-transform: uppercase;
160
+ letter-spacing: 0.04em;
161
+ }
162
+
163
+ /* The small muted explanation line under a control. */
164
+ .hk-settings-hint {
165
+ margin: 0;
166
+ font-size: var(--text-xs);
167
+ line-height: 1.5;
168
+ color: rgb(var(--color-muted));
169
+ }
@@ -0,0 +1,249 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
4
+ import { Languages, Globe } from "lucide-vue-next";
5
+ import {
6
+ HkSettingsBody as HSettingsBody,
7
+ HkSettingsDialog as HSettingsDialog,
8
+ HkSettingsGroup as HSettingsGroup,
9
+ HkSettingsHint as HSettingsHint,
10
+ HkSettingsSub as HSettingsSub,
11
+ } from "./HkSettingsDialog";
12
+
13
+ function mount(component: ReturnType<typeof defineComponent>) {
14
+ const container = document.createElement("div");
15
+ document.body.appendChild(container);
16
+ const app = createApp(component);
17
+ app.mount(container);
18
+ return { container, unmount: () => { app.unmount(); container.remove(); } };
19
+ }
20
+
21
+ const SECTIONS = [
22
+ { key: "language", label: "Language", icon: Languages },
23
+ { key: "network", label: "Network", icon: Globe },
24
+ { key: "locked", label: "Locked", disabled: true },
25
+ ];
26
+
27
+ const Host = defineComponent({
28
+ setup() {
29
+ const section = ref("language");
30
+ return () => (
31
+ <HSettingsBody sections={SECTIONS} v-model:section={section.value}
32
+ onUpdate:section={(v: string) => (section.value = v)}>
33
+ {{
34
+ language: () => <div data-test="pane-language">lang pane</div>,
35
+ network: () => <div data-test="pane-network">net pane</div>,
36
+ locked: () => <div data-test="pane-locked">locked pane</div>,
37
+ }}
38
+ </HSettingsBody>
39
+ );
40
+ },
41
+ });
42
+
43
+ describe("HSettingsBody", () => {
44
+ it("renders every rail entry and the first section's pane", () => {
45
+ const { container, unmount } = mount(Host);
46
+ const items = container.querySelectorAll<HTMLButtonElement>(".hk-settings__rail-item");
47
+ expect(items).toHaveLength(3);
48
+ expect(items[0].textContent).toContain("Language");
49
+ expect(items[0].querySelector(".hk-settings__rail-icon svg")).toBeTruthy();
50
+ // First section active by default (internal state, unbound prop).
51
+ expect(items[0].classList.contains("is-active")).toBe(true);
52
+ expect(container.querySelector('[data-test="pane-language"]')).toBeTruthy();
53
+ expect(container.querySelector('[data-test="pane-network"]')).toBeNull();
54
+ unmount();
55
+ });
56
+
57
+ it("switches the pane on rail click and reports aria-current", async () => {
58
+ const { container, unmount } = mount(Host);
59
+ const items = container.querySelectorAll<HTMLButtonElement>(".hk-settings__rail-item");
60
+ items[1].click();
61
+ await nextTick();
62
+ expect(container.querySelector('[data-test="pane-network"]')).toBeTruthy();
63
+ expect(items[1].getAttribute("aria-current")).toBe("true");
64
+ expect(items[0].getAttribute("aria-current")).toBeNull();
65
+ unmount();
66
+ });
67
+
68
+ it("never activates a disabled entry", async () => {
69
+ const { container, unmount } = mount(Host);
70
+ const items = container.querySelectorAll<HTMLButtonElement>(".hk-settings__rail-item");
71
+ expect(items[2].disabled).toBe(true);
72
+ items[2].click();
73
+ await nextTick();
74
+ // Still on the first section.
75
+ expect(container.querySelector('[data-test="pane-language"]')).toBeTruthy();
76
+ expect(items[2].classList.contains("is-active")).toBe(false);
77
+ unmount();
78
+ });
79
+
80
+ it("restarts the pane scroll on section switch", async () => {
81
+ const scrolled = ref(false);
82
+ const Probe = defineComponent({
83
+ setup() {
84
+ const section = ref("language");
85
+ return () => (
86
+ <HSettingsBody
87
+ sections={SECTIONS.slice(0, 2)}
88
+ section={section.value}
89
+ onUpdate:section={(v: string) => (section.value = v)}
90
+ >
91
+ {{
92
+ language: () => <div style="height: 2000px">lang</div>,
93
+ network: () => <div style="height: 2000px">net</div>,
94
+ }}
95
+ </HSettingsBody>
96
+ );
97
+ },
98
+ });
99
+ const { container, unmount } = mount(Probe);
100
+ const pane = container.querySelector<HTMLElement>(".hk-settings__pane")!;
101
+ pane.scrollTop = 500;
102
+ const items = container.querySelectorAll<HTMLButtonElement>(".hk-settings__rail-item");
103
+ items[1].click();
104
+ await nextTick();
105
+ await nextTick();
106
+ scrolled.value = pane.scrollTop === 0;
107
+ expect(scrolled.value).toBe(true);
108
+ unmount();
109
+ });
110
+
111
+ it("honors an externally driven section change (deep-link landing)", async () => {
112
+ const section = ref("language");
113
+ const Deep = defineComponent({
114
+ setup() {
115
+ return () => (
116
+ <HSettingsBody sections={SECTIONS.slice(0, 2)} section={section.value}>
117
+ {{
118
+ language: () => <div data-test="pane-language" />,
119
+ network: () => <div data-test="pane-network" />,
120
+ }}
121
+ </HSettingsBody>
122
+ );
123
+ },
124
+ });
125
+ const { container, unmount } = mount(Deep);
126
+ section.value = "network";
127
+ await nextTick();
128
+ expect(container.querySelector('[data-test="pane-network"]')).toBeTruthy();
129
+ unmount();
130
+ });
131
+ });
132
+
133
+ describe("HSettingsDialog", () => {
134
+ it("hosts the shell inside an open HModal and lands on the bound section", async () => {
135
+ const section = ref("network");
136
+ const Dialog = defineComponent({
137
+ setup() {
138
+ return () => (
139
+ <HSettingsDialog
140
+ modelValue
141
+ title="Settings"
142
+ sections={SECTIONS.slice(0, 2)}
143
+ section={section.value}
144
+ onUpdate:section={(v: string) => (section.value = v)}
145
+ >
146
+ {{
147
+ language: () => <div data-test="pane-language" />,
148
+ network: () => <div data-test="pane-network" />,
149
+ }}
150
+ </HSettingsDialog>
151
+ );
152
+ },
153
+ });
154
+ const { unmount } = mount(Dialog);
155
+ await nextTick();
156
+ await new Promise((r) => setTimeout(r, 0));
157
+ // HkModal teleports to document.body — the shell lives there, not in
158
+ // the mount container.
159
+ const host = document.body.querySelector(".hk-settings-host");
160
+ expect(host).toBeTruthy();
161
+ expect(host!.querySelector(".hk-settings__rail")).toBeTruthy();
162
+ // The bound section won over the default-first.
163
+ expect(host!.querySelector('[data-test="pane-network"]')).toBeTruthy();
164
+ unmount();
165
+ });
166
+ });
167
+
168
+ describe("settings content vocabulary", () => {
169
+ it("renders group / sub / hint with their titles", () => {
170
+ const { container, unmount } = mount(
171
+ defineComponent({
172
+ setup() {
173
+ return () => (
174
+ <HSettingsGroup title="Network">
175
+ <HSettingsSub title="Proxy">
176
+ <HSettingsHint>Applies to every request.</HSettingsHint>
177
+ </HSettingsSub>
178
+ </HSettingsGroup>
179
+ );
180
+ },
181
+ }),
182
+ );
183
+ expect(container.querySelector(".hk-settings-group-title")!.textContent).toBe("Network");
184
+ expect(container.querySelector(".hk-settings-sub-title")!.textContent).toBe("Proxy");
185
+ expect(container.querySelector(".hk-settings-hint")!.textContent)
186
+ .toContain("Applies to every request");
187
+ unmount();
188
+ });
189
+
190
+ it("renders a group without a title (untitled card)", () => {
191
+ const { container, unmount } = mount(
192
+ defineComponent({
193
+ setup() {
194
+ return () => h(HSettingsGroup, null, { default: () => "body" });
195
+ },
196
+ }),
197
+ );
198
+ expect(container.querySelector(".hk-settings-group-title")).toBeNull();
199
+ unmount();
200
+ });
201
+ });
202
+
203
+ describe("settings dialog SCSS host contract (source-level)", () => {
204
+ // The stable-frame contract is three rules in the SCSS that happy-dom's
205
+ // cascade-free environment cannot exercise behaviorally — pin them at
206
+ // the source so a silent deletion turns this red (the R1 mutation-d
207
+ // gap). Known-positive control: the base rail rule every variant
208
+ // depends on.
209
+ // vitest serves modules through its own scheme, so import.meta.url is
210
+ // not a file URL here; the suite's cwd is packages/vue.
211
+ const scss = readFileSync("src/components/HkSettingsDialog.scss", "utf8");
212
+
213
+ it("self-check: the extractor sees a rule that exists", () => {
214
+ // The shell rules are BEM-nested in the source (&__rail-item inside
215
+ // .hk-settings) — the positive control matches the nested form.
216
+ expect(scss).toContain("&__rail-item {");
217
+ });
218
+
219
+ it("pins the stable-frame host rules (idle modal scrollbar, flush edge)", () => {
220
+ expect(scss).toContain(".hk-settings-host .hk-modal-body-inner {");
221
+ expect(scss).toContain("height: var(--hk-settings-height, calc(70vh - 6.5rem));");
222
+ expect(scss).toContain("margin-right: calc(-1 * var(--hk-modal-padding-body, 1.5rem));");
223
+ });
224
+
225
+ it("never activates a disabled section through a deep-linked prop", async () => {
226
+ const Deep = defineComponent({
227
+ setup() {
228
+ return () => (
229
+ <HSettingsBody sections={SECTIONS} section="locked">
230
+ {{
231
+ language: () => <div data-test="pane-language" />,
232
+ network: () => <div data-test="pane-network" />,
233
+ locked: () => <div data-test="pane-locked" />,
234
+ }}
235
+ </HSettingsBody>
236
+ );
237
+ },
238
+ });
239
+ const { container, unmount } = mount(Deep);
240
+ await nextTick();
241
+ // The disabled key did not win: first usable section shows, the
242
+ // locked pane never renders, its rail entry stays grey.
243
+ expect(container.querySelector('[data-test="pane-language"]')).toBeTruthy();
244
+ expect(container.querySelector('[data-test="pane-locked"]')).toBeNull();
245
+ const items = container.querySelectorAll<HTMLButtonElement>(".hk-settings__rail-item");
246
+ expect(items[2].classList.contains("is-active")).toBe(false);
247
+ unmount();
248
+ });
249
+ });
@@ -0,0 +1,237 @@
1
+ import {
2
+ defineComponent,
3
+ h,
4
+ nextTick,
5
+ ref,
6
+ watch,
7
+ type Component,
8
+ type PropType,
9
+ } from "vue";
10
+
11
+ import HModal from "./HkModal";
12
+ import "./HkSettingsDialog.scss";
13
+
14
+ /** One rail entry of the settings dialog. */
15
+ export interface HkSettingsSection {
16
+ /** Identity of the section — also the name of the slot that renders its
17
+ * content pane (`{ [key]: () => … }`). */
18
+ key: string;
19
+ /** Rail label. */
20
+ label: string;
21
+ /** Leading rail icon (a lucide-style component; rendered at 16px). */
22
+ icon?: Component;
23
+ /** A disabled entry renders but cannot be activated. */
24
+ disabled?: boolean;
25
+ }
26
+
27
+ /**
28
+ * HkSettingsBody — the sectioned settings shell: a left rail of icon+label
29
+ * nav entries and a right scrollable pane rendering the active section's
30
+ * slot. Extracted from the pattern wowsp, the flasher and the admin
31
+ * consoles each hand-rolled (the "classic settings window" anatomy).
32
+ *
33
+ * Standalone by design: the BODY can be embedded in a page (the phone
34
+ * surface pattern — wowsp renders it inside its own /settings route) or
35
+ * inside {@link HSettingsDialog}, which wraps it in an HModal window.
36
+ * Section identity is a `v-model:section` so openers can land on a
37
+ * specific section; leaving it unbound keeps the state internal
38
+ * (first section active).
39
+ *
40
+ * Scroll ownership: the pane is the only scroller — switching sections
41
+ * restarts it from the top (arriving on a half-scrolled pane reads as a
42
+ * broken page). Inside the dialog the modal body's own scroller is made
43
+ * mathematically idle by the host height rule (see the SCSS); on a page
44
+ * the host manages its own layout.
45
+ */
46
+ export const HkSettingsBody = defineComponent({
47
+ name: "HkSettingsBody",
48
+ props: {
49
+ /** Rail entries, in rail order. */
50
+ sections: { type: Array as PropType<HkSettingsSection[]>, required: true },
51
+ /** Active section key (v-model:section). Unbound = internal state. */
52
+ section: { type: String, default: undefined },
53
+ /** Accessible name for the rail navigation. */
54
+ navLabel: { type: String, default: undefined },
55
+ },
56
+ emits: {
57
+ "update:section": (key: string) => typeof key === "string",
58
+ },
59
+ setup(props, { emit, slots }) {
60
+ const internal = ref(props.sections[0]?.key ?? "");
61
+ // Controlled while a section prop is bound: external changes flow in,
62
+ // clicks flow out. Unbound: the internal ref alone decides. Immediate
63
+ // so an opener that mounts ALREADY on a deep-linked section
64
+ // (section="network" from the first render) wins over the first-entry
65
+ // default — a non-immediate watch would never fire for it.
66
+ watch(
67
+ () => props.section,
68
+ (key) => {
69
+ if (key == null) return;
70
+ // A disabled (or unknown) key never activates — a host that
71
+ // deep-links to one keeps the first usable section instead
72
+ // (R1 gap: the locked pane used to render with its rail entry
73
+ // greyed out, contradicting "renders but cannot be activated").
74
+ if (props.sections.some((sec) => sec.key === key && !sec.disabled)) {
75
+ internal.value = key;
76
+ }
77
+ },
78
+ { immediate: true },
79
+ );
80
+ const active = () =>
81
+ props.sections.some((s) => s.key === internal.value)
82
+ ? internal.value
83
+ : (props.sections[0]?.key ?? "");
84
+
85
+ const paneRef = ref<HTMLElement | null>(null);
86
+ // Section switches restart the pane from the top.
87
+ watch(active, () => {
88
+ void nextTick(() => {
89
+ if (paneRef.value) paneRef.value.scrollTop = 0;
90
+ });
91
+ });
92
+
93
+ function pick(key: string) {
94
+ const entry = props.sections.find((s) => s.key === key);
95
+ if (!entry || entry.disabled) return;
96
+ internal.value = key;
97
+ emit("update:section", key);
98
+ }
99
+
100
+ return () => (
101
+ <div class="hk-settings">
102
+ <nav class="hk-settings__rail" aria-label={props.navLabel}>
103
+ {props.sections.map((s) => {
104
+ const Icon = s.icon;
105
+ const on = active() === s.key && !s.disabled;
106
+ return (
107
+ <button
108
+ key={s.key}
109
+ type="button"
110
+ class={["hk-settings__rail-item", on ? "is-active" : ""]}
111
+ aria-current={on ? "true" : undefined}
112
+ aria-disabled={s.disabled || undefined}
113
+ disabled={s.disabled}
114
+ onClick={() => pick(s.key)}
115
+ >
116
+ {typeof Icon === "function" ? (
117
+ <span class="hk-settings__rail-icon" aria-hidden="true">
118
+ <Icon size={16} />
119
+ </span>
120
+ ) : null}
121
+ <span class="hk-settings__rail-label">{s.label}</span>
122
+ </button>
123
+ );
124
+ })}
125
+ </nav>
126
+ <div class="hk-settings__pane" ref={paneRef}>
127
+ {slots[active()]?.()}
128
+ </div>
129
+ </div>
130
+ );
131
+ },
132
+ });
133
+
134
+ /**
135
+ * HkSettingsDialog — the settings window: an HModal carrying the
136
+ * {@link HSettingsBody} shell with a stable frame height, so the modal
137
+ * body's floating scrollbar stays idle and the pane's edge bar is the
138
+ * only scrollbar (the wowsp-proven contract). Width defaults to the
139
+ * settings-standard 58rem; `height` styles the shell (the 70vh default
140
+ * fits a modal with title and paddings).
141
+ *
142
+ * Like every HkModal composite it bottom-sheets under 768px; hosts that
143
+ * want a phone page instead render HSettingsBody in their own route and
144
+ * simply never mount this dialog there.
145
+ */
146
+ export const HkSettingsDialog = defineComponent({
147
+ name: "HkSettingsDialog",
148
+ props: {
149
+ modelValue: { type: Boolean, default: false },
150
+ title: { type: String, default: "" },
151
+ /** Modal max-width (CSS value or HkModal preset). */
152
+ width: { type: String, default: "58rem" },
153
+ /** Rail entries — passed through to the body. */
154
+ sections: { type: Array as PropType<HkSettingsSection[]>, required: true },
155
+ /** Active section key (v-model:section). */
156
+ section: { type: String, default: undefined },
157
+ /** Accessible name for the rail navigation. */
158
+ navLabel: { type: String, default: undefined },
159
+ /** Shell height — the stable frame the pane scrolls inside. */
160
+ height: { type: String, default: "calc(70vh - 6.5rem)" },
161
+ },
162
+ emits: {
163
+ "update:modelValue": (v: boolean) => typeof v === "boolean",
164
+ "update:section": (key: string) => typeof key === "string",
165
+ },
166
+ setup(props, { emit, slots }) {
167
+ return () => (
168
+ <HModal
169
+ modelValue={props.modelValue}
170
+ onUpdate:modelValue={(v: boolean) => emit("update:modelValue", v)}
171
+ title={props.title}
172
+ width={props.width}
173
+ contentClass="hk-settings-host"
174
+ >
175
+ {/* The per-section slots flow through h()'s third argument — the
176
+ canonical slots channel. JSX children would wrap the slots
177
+ object into a vnode child and the keys would never reach the
178
+ body (caught by the dialog mount test). */}
179
+ {h(
180
+ HkSettingsBody,
181
+ {
182
+ sections: props.sections,
183
+ section: props.section,
184
+ navLabel: props.navLabel,
185
+ "onUpdate:section": (key: string) => emit("update:section", key),
186
+ style: { "--hk-settings-height": props.height },
187
+ },
188
+ slots,
189
+ )}
190
+ </HModal>
191
+ );
192
+ },
193
+ });
194
+
195
+ /**
196
+ * HSettingsGroup — one titled card in the content pane: the standard
197
+ * container of a section's controls. Slightly translucent so cards sit
198
+ * quietly on image wallpapers.
199
+ */
200
+ export const HkSettingsGroup = defineComponent({
201
+ name: "HkSettingsGroup",
202
+ props: {
203
+ title: { type: String, default: undefined },
204
+ },
205
+ setup(props, { slots }) {
206
+ return () => (
207
+ <section class="hk-settings-group">
208
+ {props.title ? <h3 class="hk-settings-group-title">{props.title}</h3> : null}
209
+ {slots.default?.()}
210
+ </section>
211
+ );
212
+ },
213
+ });
214
+
215
+ /** HSettingsSub — a titled sub-block inside a group. */
216
+ export const HkSettingsSub = defineComponent({
217
+ name: "HkSettingsSub",
218
+ props: {
219
+ title: { type: String, default: undefined },
220
+ },
221
+ setup(props, { slots }) {
222
+ return () => (
223
+ <div class="hk-settings-sub">
224
+ {props.title ? <h4 class="hk-settings-sub-title">{props.title}</h4> : null}
225
+ {slots.default?.()}
226
+ </div>
227
+ );
228
+ },
229
+ });
230
+
231
+ /** HSettingsHint — the small muted explanation line under a control. */
232
+ export const HkSettingsHint = defineComponent({
233
+ name: "HkSettingsHint",
234
+ setup(_props, { slots }) {
235
+ return () => <p class="hk-settings-hint">{slots.default?.()}</p>;
236
+ },
237
+ });
package/src/index.ts CHANGED
@@ -435,6 +435,15 @@ export {
435
435
  type HAboutLinkIcon,
436
436
  } from "./components/HkAboutModal";
437
437
  export { HkLogWindow as HLogWindow, type HLogTab } from "./components/HkLogWindow";
438
+ export {
439
+ HkSettingsBody as HSettingsBody,
440
+ HkSettingsDialog as HSettingsDialog,
441
+ HkSettingsGroup as HSettingsGroup,
442
+ HkSettingsSub as HSettingsSub,
443
+ HkSettingsHint as HSettingsHint,
444
+ type HkSettingsSection,
445
+ } from "./components/HkSettingsDialog";
446
+
438
447
  export { HkCookieConsent as HCookieConsent } from "./components/HkCookieConsent";
439
448
  export {
440
449
  HkAttachmentModal as HAttachmentModal,