@celestia-island/hikari 0.55.171 → 0.55.174

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.171",
3
+ "version": "0.55.174",
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,56 @@
1
+ /* HkHeaderPicker — the header-centre selector. The trigger reuses the
2
+ * family's dropdown chrome (.hk-select-trigger) but adapts to the
3
+ * header band: self-sized (the centre slot hugs its content, unlike a
4
+ * form field that fills its row) and band-height compact. The panel
5
+ * geometry/behaviour is HkSelectPanel's, unchanged. */
6
+
7
+ .hk-header-picker {
8
+ display: inline-flex;
9
+ min-width: 0;
10
+ }
11
+
12
+ .hk-header-picker-trigger {
13
+ width: auto;
14
+ /* Header-band compact: the button group beside it runs ~28px; the
15
+ * trigger stays a touch taller for its label but must not drive the
16
+ * header's own height. */
17
+ min-height: var(--space-32);
18
+ padding-block: var(--space-4);
19
+ padding-inline: var(--space-12) var(--space-28);
20
+ font-size: var(--text-sm);
21
+ font-weight: 600;
22
+ max-width: 16rem;
23
+ }
24
+
25
+ .hk-header-picker-trigger .hk-select-value {
26
+ overflow: hidden;
27
+ text-overflow: ellipsis;
28
+ white-space: nowrap;
29
+ }
30
+
31
+ .hk-header-picker-trigger .hk-select-arrow {
32
+ position: absolute;
33
+ inset-inline-end: var(--space-8);
34
+ }
35
+
36
+ /* ── Standardized rows (label + row verbs) ────────────────────────── */
37
+
38
+ .hk-header-picker-option {
39
+ display: flex;
40
+ align-items: center;
41
+ gap: var(--space-8);
42
+ justify-content: space-between;
43
+ }
44
+
45
+ .hk-header-picker-option-label {
46
+ overflow: hidden;
47
+ text-overflow: ellipsis;
48
+ white-space: nowrap;
49
+ }
50
+
51
+ .hk-header-picker-option-actions {
52
+ display: inline-flex;
53
+ align-items: center;
54
+ gap: var(--space-2);
55
+ flex: none;
56
+ }
@@ -0,0 +1,140 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, ref } from "vue";
3
+
4
+ import HkHeaderPicker, { type HkPickerOption } from "./HkHeaderPicker";
5
+
6
+ /**
7
+ * HkHeaderPicker — the header-centre selector (the button group's
8
+ * sibling). These pins hold the standardized contract: trigger chrome,
9
+ * selection, and the per-row verbs (rename / delete / reorder) that
10
+ * "manage a dynamic set" pages hang on this one component. (Repo test
11
+ * convention: raw createApp + container queries; the panel teleports
12
+ * to body, so row queries go through document.)
13
+ */
14
+
15
+ const OPTIONS: HkPickerOption[] = [
16
+ { value: "hub-1", label: "Hub One", editable: true, removable: true },
17
+ { value: "hub-2", label: "Hub Two", removable: true, locked: true },
18
+ { value: "hub-3", label: "Hub Three" },
19
+ ];
20
+
21
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
22
+
23
+ function mountPicker(overrides: Record<string, unknown>, handlers: Record<string, unknown> = {}) {
24
+ const container = document.createElement("div");
25
+ document.body.appendChild(container);
26
+ const Host = defineComponent({
27
+ setup() {
28
+ const model = ref((overrides.modelValue as string) ?? "hub-1");
29
+ return () =>
30
+ h(HkHeaderPicker, {
31
+ modelValue: model.value,
32
+ "onUpdate:modelValue": (v: string) => {
33
+ model.value = v;
34
+ (handlers.onUpdateModelValue as ((v: string) => void) | undefined)?.(v);
35
+ },
36
+ options: OPTIONS,
37
+ panelTitle: "Pick a hub",
38
+ ariaLabel: "Hub picker",
39
+ ...overrides,
40
+ ...handlers,
41
+ });
42
+ },
43
+ });
44
+ const app = createApp({ render: () => h(Host) });
45
+ app.mount(container);
46
+ mounts.push({ app, container });
47
+ return container;
48
+ }
49
+
50
+ afterEach(() => {
51
+ for (const { app, container } of mounts) {
52
+ app.unmount();
53
+ container.remove();
54
+ }
55
+ mounts.length = 0;
56
+ });
57
+
58
+ describe("HkHeaderPicker", () => {
59
+ it("renders the family dropdown trigger reading the current choice", () => {
60
+ const c = mountPicker({}, {});
61
+ const trigger = c.querySelector("button.hk-select-trigger")!;
62
+ expect(trigger, "family trigger chrome must render").not.toBeNull();
63
+ expect(trigger.className).toContain("hk-header-picker-trigger");
64
+ expect(trigger.textContent).toContain("Hub One");
65
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
66
+ });
67
+
68
+ it("opens the panel on trigger click and selects a row", async () => {
69
+ const selected: string[] = [];
70
+ const c = mountPicker({}, { onUpdateModelValue: (v: string) => selected.push(v) });
71
+ (c.querySelector("button.hk-select-trigger") as HTMLElement).click();
72
+ await vi.waitFor(() => {
73
+ expect(document.querySelectorAll(".hk-header-picker-option").length).toBe(3);
74
+ });
75
+ const rows = Array.from(document.querySelectorAll(".hk-header-picker-option"));
76
+ (rows[2] as HTMLElement).click();
77
+ expect(selected).toEqual(["hub-3"]);
78
+ });
79
+
80
+ it("emits edit and remove for the standardized row verbs without selecting", async () => {
81
+ const onEdit = vi.fn();
82
+ const onRemove = vi.fn();
83
+ const selected: string[] = [];
84
+ const c = mountPicker({}, {
85
+ onEdit,
86
+ onRemove,
87
+ onUpdateModelValue: (v: string) => selected.push(v),
88
+ });
89
+ (c.querySelector("button.hk-select-trigger") as HTMLElement).click();
90
+ await vi.waitFor(() => {
91
+ expect(document.querySelectorAll(".hk-header-picker-option").length).toBe(3);
92
+ });
93
+ const rows = Array.from(document.querySelectorAll(".hk-header-picker-option"));
94
+ // Row 0 carries BOTH verbs (editable + removable); row 1 only remove;
95
+ // row 2 declares neither flag — no verbs at all.
96
+ const editBtn = rows[0].querySelector('button[aria-label="Rename"]');
97
+ const removeBtn0 = rows[0].querySelector('button[aria-label="Delete"]');
98
+ const removeBtn1 = rows[1].querySelector('button[aria-label="Delete"]');
99
+ expect(editBtn).not.toBeNull();
100
+ expect(removeBtn0).not.toBeNull();
101
+ expect(removeBtn1).not.toBeNull();
102
+ expect(rows[2].querySelector('button[aria-label="Delete"]')).toBeNull();
103
+ (editBtn as HTMLElement).click();
104
+ (removeBtn1 as HTMLElement).click();
105
+ expect(onEdit).toHaveBeenCalledWith("hub-1");
106
+ expect(onRemove).toHaveBeenCalledWith("hub-2");
107
+ // A verb press must NOT double as a row selection.
108
+ expect(selected).toEqual([]);
109
+ });
110
+
111
+ it("mounts the rows on a draggable list when reorderable", async () => {
112
+ const onReorder = vi.fn();
113
+ const c = mountPicker({ reorderable: true }, { onReorder });
114
+ (c.querySelector("button.hk-select-trigger") as HTMLElement).click();
115
+ await vi.waitFor(() => {
116
+ expect(document.querySelector(".hk-draggable-list")).not.toBeNull();
117
+ });
118
+ // The locked row (hub-2) marks its grip data-hidden (lane reserved)
119
+ // while the other two rows keep draggable grips.
120
+ const grips = document.querySelectorAll(".hk-draggable-list-handle");
121
+ expect(grips.length).toBe(3);
122
+ expect(document.querySelectorAll('.hk-draggable-list-handle[data-hidden]').length).toBe(1);
123
+ });
124
+
125
+ it("declares the placement prop with the top-down default (mirror-of-bottom contract)", () => {
126
+ // The panel is teleported to body, so the through-wiring is pinned at
127
+ // the contract level: the prop exists, defaults to the header's
128
+ // downward growth, and the render passes it straight through
129
+ // (`placement={props.placement}` in HkHeaderPicker.tsx).
130
+ expect(HkHeaderPicker.props!.placement).toBeDefined();
131
+ expect((HkHeaderPicker.props!.placement as { default: string }).default).toBe("bottom-start");
132
+ });
133
+
134
+ it("disables the trigger when disabled", () => {
135
+ const c = mountPicker({ disabled: true }, {});
136
+ expect(
137
+ (c.querySelector("button.hk-select-trigger") as HTMLButtonElement).disabled,
138
+ ).toBe(true);
139
+ });
140
+ });
@@ -0,0 +1,313 @@
1
+ import {
2
+ computed,
3
+ defineComponent,
4
+ nextTick,
5
+ ref,
6
+ watch,
7
+ type PropType,
8
+ } from "vue";
9
+
10
+ import { ChevronDown, Pencil, Trash2 } from "lucide-vue-next";
11
+
12
+ import { useBreakpoint } from "../runtime/useBreakpoint";
13
+ import HkDraggableList from "./HkDraggableList";
14
+ import HkIconButton from "./HkIconButton";
15
+ import HkSelectPanel, { type SelectPanelPlacement } from "./HkSelectPanel";
16
+ import "./HkHeaderPicker.scss";
17
+
18
+ /** One choice of a header picker. The selection surface is the same
19
+ * model as a select's; the three extra flags light the STANDARDIZED
20
+ * per-row affordances (R: 2026-10-15 header-picker direction) — a row
21
+ * may be renamed, deleted, or (when the whole picker is `reorderable`)
22
+ * dragged into a new order, so "manage the dynamic set" pages — flash
23
+ * hubs, office documents, tag rosters — hang their maintenance verbs
24
+ * on the SAME component instead of one bespoke menu each. */
25
+ export interface HkPickerOption {
26
+ value: string;
27
+ label: string;
28
+ disabled?: boolean;
29
+ /** Hidden grip: the row never drags (a pinned default), but the
30
+ * handle lane stays reserved so the column does not jump. */
31
+ locked?: boolean;
32
+ /** Show the standardized rename action (emits `edit`). */
33
+ editable?: boolean;
34
+ /** Show the standardized delete action (emits `remove`). The picker
35
+ * itself never confirms — removal is the HOST's decision; this just
36
+ * makes the affordance uniform. */
37
+ removable?: boolean;
38
+ }
39
+
40
+ /**
41
+ * HkHeaderPicker — the header-centre SELECTOR, the button group's
42
+ * sibling facility. Where `HkTabs` (segmented) serves a small closed
43
+ * enum, this serves an OPEN or machine-managed set (hubs, documents,
44
+ * tags): a dropdown-style trigger reading the current choice, and an
45
+ * opened panel that mirrors the bottom-centre pickers symmetrically —
46
+ *
47
+ * - phone: the same bottom sheet they use, sliding up from the bottom
48
+ * edge (an edge gesture, identical muscle memory);
49
+ * - desktop: a popout anchored at the trigger growing DOWNWARD from
50
+ * the header band — the mirror of a bottom-pinned picker growing up.
51
+ *
52
+ * The panel is HkSelectPanel (popup-manager, z-bands, scrim/grabber),
53
+ * so the surface behaves like every other dropdown in the family; this
54
+ * component owns the header-band trigger chrome, the keyboard model,
55
+ * and the standardized row verbs (rename / delete / drag-reorder).
56
+ * Removing/renaming emits to the host — this component never mutates
57
+ * the set, never confirms — the host decides the policy, the family
58
+ * keeps the look.
59
+ */
60
+ export default defineComponent({
61
+ name: "HkHeaderPicker",
62
+ props: {
63
+ modelValue: { type: String, default: "" },
64
+ options: {
65
+ type: Array as PropType<HkPickerOption[]>,
66
+ default: () => [] as HkPickerOption[],
67
+ },
68
+ /** Placeholder when nothing is selected yet. */
69
+ placeholder: { type: String, default: "" },
70
+ /** Title for the OPENED surface only (sheet header / popout a11y). */
71
+ panelTitle: { type: String, default: undefined },
72
+ disabled: { type: Boolean, default: false },
73
+ /** Show drag grips and emit `reorder` (host owns the new order). */
74
+ reorderable: { type: Boolean, default: false },
75
+ /** Desktop-popout side relative to the trigger. The header sits at
76
+ * the viewport's top, so the popout grows DOWNWARD (bottom-*) —
77
+ * the mirror of bottom-pinned pickers; auto-flip still rescues a
78
+ * viewport that cannot fit it. */
79
+ placement: {
80
+ type: String as PropType<SelectPanelPlacement>,
81
+ default: "bottom-start",
82
+ },
83
+ /** Accessible name of the trigger itself. */
84
+ ariaLabel: { type: String, default: undefined },
85
+ },
86
+ emits: {
87
+ "update:modelValue": (_value: string) => true,
88
+ /** A row's standardized rename action was pressed. */
89
+ edit: (_value: string) => true,
90
+ /** A row's standardized delete action was pressed. */
91
+ remove: (_value: string) => true,
92
+ /** Drag-reorder finished (reorderable): host reorders the array. */
93
+ reorder: (_fromIndex: number, _toIndex: number) => true,
94
+ },
95
+ setup(props, { emit }) {
96
+ const isOpen = ref(false);
97
+ const triggerRef = ref<HTMLElement>();
98
+ const panelExpose = ref<{ panelEl: () => HTMLElement | null }>();
99
+ const highlightedIndex = ref(-1);
100
+
101
+ // Pointer-hover highlighting only applies to the desktop popout —
102
+ // the mobile sheet rows never had it (spurious tap-highlight).
103
+ const { isMobile } = useBreakpoint();
104
+ const sheetMode = computed(() => isMobile.value);
105
+
106
+ const options = computed(() => props.options ?? []);
107
+
108
+ const displayLabel = computed(() => {
109
+ if (!props.modelValue) return props.placeholder || "";
110
+ return (
111
+ options.value.find((o) => o.value === props.modelValue)?.label ??
112
+ props.modelValue
113
+ );
114
+ });
115
+
116
+ function toggle() {
117
+ if (props.disabled) return;
118
+ isOpen.value = !isOpen.value;
119
+ }
120
+
121
+ function select(value: string) {
122
+ emit("update:modelValue", value);
123
+ isOpen.value = false;
124
+ }
125
+
126
+ function onEdit(e: MouseEvent, value: string) {
127
+ // The row's verb must not double as a row selection.
128
+ e.stopPropagation();
129
+ emit("edit", value);
130
+ }
131
+
132
+ function onRemove(e: MouseEvent, value: string) {
133
+ e.stopPropagation();
134
+ emit("remove", value);
135
+ }
136
+
137
+ function onReorder(from: number, to: number) {
138
+ emit("reorder", from, to);
139
+ }
140
+
141
+ function onTriggerKeydown(e: KeyboardEvent) {
142
+ if (props.disabled) return;
143
+ if (e.key === "Enter" || e.key === " ") {
144
+ e.preventDefault();
145
+ toggle();
146
+ } else if (e.key === "ArrowDown") {
147
+ e.preventDefault();
148
+ if (!isOpen.value) {
149
+ isOpen.value = true;
150
+ highlightedIndex.value = 0;
151
+ } else {
152
+ highlightedIndex.value =
153
+ (highlightedIndex.value + 1) % options.value.length;
154
+ }
155
+ } else if (e.key === "ArrowUp") {
156
+ e.preventDefault();
157
+ if (!isOpen.value) {
158
+ isOpen.value = true;
159
+ } else {
160
+ highlightedIndex.value =
161
+ (highlightedIndex.value - 1 + options.value.length) %
162
+ options.value.length;
163
+ }
164
+ } else if (e.key === "Escape") {
165
+ // Consume ONLY when it closed something — the trigger may sit
166
+ // inside a dialog surface whose own Escape must survive.
167
+ if (isOpen.value) {
168
+ e.preventDefault();
169
+ e.stopPropagation();
170
+ }
171
+ isOpen.value = false;
172
+ }
173
+ }
174
+
175
+ function onPopoutKeydown(e: KeyboardEvent) {
176
+ if (e.key === "ArrowDown") {
177
+ e.preventDefault();
178
+ highlightedIndex.value =
179
+ (highlightedIndex.value + 1) % options.value.length;
180
+ } else if (e.key === "ArrowUp") {
181
+ e.preventDefault();
182
+ highlightedIndex.value =
183
+ (highlightedIndex.value - 1 + options.value.length) %
184
+ options.value.length;
185
+ } else if (e.key === "Enter") {
186
+ e.preventDefault();
187
+ const opt = options.value[highlightedIndex.value];
188
+ if (opt && !opt.disabled) select(opt.value);
189
+ } else if (e.key === "Escape") {
190
+ e.preventDefault();
191
+ isOpen.value = false;
192
+ }
193
+ }
194
+
195
+ function scrollToHighlighted() {
196
+ const root = panelExpose.value?.panelEl();
197
+ const el = root?.querySelector(`[data-select-index="${highlightedIndex.value}"]`);
198
+ el?.scrollIntoView({ block: "nearest" });
199
+ }
200
+
201
+ watch(isOpen, (open) => {
202
+ if (open) {
203
+ const idx = options.value.findIndex((o) => o.value === props.modelValue);
204
+ highlightedIndex.value = idx;
205
+ nextTick(() => scrollToHighlighted());
206
+ } else {
207
+ highlightedIndex.value = -1;
208
+ }
209
+ });
210
+
211
+ watch(highlightedIndex, () => scrollToHighlighted());
212
+
213
+ /** One standardized row: label + the affordances its flags light.
214
+ * Selecting the row picks it; the verb buttons stop propagation so
215
+ * a rename/delete never doubles as a selection. */
216
+ const renderRow = (opt: HkPickerOption, i: number) => (
217
+ <div
218
+ key={opt.value}
219
+ class="hk-select-option hk-header-picker-option"
220
+ data-highlighted={highlightedIndex.value === i || undefined}
221
+ data-select-index={i}
222
+ data-checked={opt.value === props.modelValue || undefined}
223
+ data-disabled={opt.disabled || undefined}
224
+ onClick={() => {
225
+ if (!opt.disabled) select(opt.value);
226
+ }}
227
+ {...(isOpen.value && !sheetMode.value
228
+ ? { onPointerenter: () => { highlightedIndex.value = i; } }
229
+ : {})}
230
+ >
231
+ <span class="hk-header-picker-option-label">{opt.label}</span>
232
+ <span class="hk-header-picker-option-actions">
233
+ {opt.editable && (
234
+ <HkIconButton
235
+ variant="ghost"
236
+ size={24}
237
+ aria-label="Rename"
238
+ onClick={(e: MouseEvent) => onEdit(e, opt.value)}
239
+ >
240
+ {{
241
+ icon: () => <Pencil size={14} />,
242
+ }}
243
+ </HkIconButton>
244
+ )}
245
+ {opt.removable && (
246
+ <HkIconButton
247
+ variant="ghost"
248
+ size={24}
249
+ aria-label="Delete"
250
+ onClick={(e: MouseEvent) => onRemove(e, opt.value)}
251
+ >
252
+ {{
253
+ icon: () => <Trash2 size={14} />,
254
+ }}
255
+ </HkIconButton>
256
+ )}
257
+ </span>
258
+ </div>
259
+ );
260
+
261
+ const renderRows = () => {
262
+ if (props.reorderable) {
263
+ return (
264
+ <HkDraggableList
265
+ items={options.value.map((o) => ({ ...o, key: o.value }))}
266
+ lockedKeys={options.value.filter((o) => o.locked).map((o) => o.value)}
267
+ onReorder={onReorder}
268
+ >
269
+ {{
270
+ item: ({ item, index }: { item: HkPickerOption; index: number }) =>
271
+ renderRow(options.value[index] ?? item, index),
272
+ }}
273
+ </HkDraggableList>
274
+ );
275
+ }
276
+ return options.value.map((opt, i) => renderRow(opt, i));
277
+ };
278
+
279
+ return () => (
280
+ <div class="hk-header-picker">
281
+ <button
282
+ ref={triggerRef}
283
+ type="button"
284
+ class="hk-select-trigger hk-header-picker-trigger"
285
+ data-state={isOpen.value ? "open" : "closed"}
286
+ data-disabled={props.disabled || undefined}
287
+ disabled={props.disabled}
288
+ aria-haspopup="listbox"
289
+ aria-expanded={isOpen.value}
290
+ aria-label={props.ariaLabel ?? props.panelTitle}
291
+ onClick={toggle}
292
+ onKeydown={onTriggerKeydown}
293
+ >
294
+ <span class="hk-select-value">{displayLabel.value || props.placeholder}</span>
295
+ <ChevronDown size={16} class="hk-select-arrow" />
296
+ </button>
297
+ <HkSelectPanel
298
+ ref={panelExpose}
299
+ open={isOpen.value}
300
+ onUpdate:open={(v: boolean) => { isOpen.value = v; }}
301
+ anchorRef={triggerRef.value ?? null}
302
+ title={props.panelTitle ?? ""}
303
+ placement={props.placement}
304
+ offset={4}
305
+ sheetOnMobile
306
+ onKeydown={onPopoutKeydown}
307
+ >
308
+ {renderRows()}
309
+ </HkSelectPanel>
310
+ </div>
311
+ );
312
+ },
313
+ });
@@ -66,3 +66,35 @@ describe("HHoverRevealAction touch reveal modes", () => {
66
66
  expect(el.className).not.toContain("is-revealed");
67
67
  });
68
68
  });
69
+
70
+ describe("HHoverRevealAction bare-click robustness", () => {
71
+ it("reveals on mousedown even without a preceding hover (synthetic clicks)", async () => {
72
+ // Test drivers dispatch a click straight at the extension's coordinates
73
+ // with no mouseenter; without the mousedown reveal the extension stays
74
+ // pointer-events:none and the click passes through to the page below.
75
+ const { el } = await mountHost();
76
+ expect(el.className).not.toContain("is-revealed");
77
+ el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
78
+ await Promise.resolve();
79
+ expect(el.className).toContain("is-revealed");
80
+ });
81
+
82
+ it("keeps the touch suppress window authoritative over mousedown", async () => {
83
+ // Touch interactions fire an emulated mousedown after the tap; during
84
+ // the touch linger the touch path owns the state and a stray emulated
85
+ // mousedown must not re-reveal (which would clear the hide timer).
86
+ vi.useFakeTimers();
87
+ const { el } = await mountHost();
88
+ el.dispatchEvent(new Event("touchstart", { bubbles: true }));
89
+ el.dispatchEvent(new Event("touchend", { bubbles: true }));
90
+ await Promise.resolve();
91
+ expect(el.className).toContain("is-revealed");
92
+ // Emulated mousedown while the touch linger owns the reveal.
93
+ el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
94
+ // After the linger expires the extension hides — the mousedown must
95
+ // not have reset the hide timer (i.e. must have been suppressed).
96
+ vi.advanceTimersByTime(3100);
97
+ await Promise.resolve();
98
+ expect(el.className).not.toContain("is-revealed");
99
+ });
100
+ });
@@ -107,6 +107,17 @@ export default defineComponent({
107
107
  reveal();
108
108
  }
109
109
 
110
+ function onMouseDown() {
111
+ // Bare synthetic clicks (test drivers dispatching a click at the
112
+ // extension's coordinates without a preceding hover move) land while
113
+ // the extension is still pointer-events:none — the click passes
114
+ // through to whatever sits underneath and the action never fires.
115
+ // A mousedown anywhere on the host reveals the extension first; the
116
+ // click that follows in the same interaction then finds it live.
117
+ if (Date.now() < suppressMouseUntil) return;
118
+ reveal();
119
+ }
120
+
110
121
  function onMouseLeave() {
111
122
  if (Date.now() < suppressMouseUntil) return;
112
123
  scheduleHide(props.hideDelay);
@@ -133,6 +144,7 @@ export default defineComponent({
133
144
  "data-placement": props.placement,
134
145
  onMouseenter: onMouseEnter,
135
146
  onMouseleave: onMouseLeave,
147
+ onMousedown: onMouseDown,
136
148
  onTouchstart: onTouchStart,
137
149
  onTouchend: onTouchEnd,
138
150
  onTouchcancel: onTouchEnd,
@@ -22,7 +22,7 @@ function setViewportWidth(px: number) {
22
22
  });
23
23
  }
24
24
 
25
- async function mountSelect() {
25
+ async function mountSelect(props: Record<string, unknown> = {}) {
26
26
  const el = document.createElement("div");
27
27
  document.body.appendChild(el);
28
28
  const app = createApp({
@@ -34,6 +34,7 @@ async function mountSelect() {
34
34
  { value: "a", label: "Option A" },
35
35
  { value: "b", label: "Option B" },
36
36
  ],
37
+ ...props,
37
38
  }),
38
39
  });
39
40
  app.mount(el);
@@ -178,3 +179,24 @@ describe("HSelect mobile sheet", () => {
178
179
  el.remove();
179
180
  });
180
181
  });
182
+
183
+ describe("HSelect desktop popout placement", () => {
184
+ afterEach(() => {
185
+ for (const el of Array.from(document.body.children)) {
186
+ if (el.className === "" || el.id === "") el.remove();
187
+ }
188
+ });
189
+
190
+ it("defaults to the form field's drop-down (bottom-start)", () => {
191
+ const vnode = h(HSelect, { modelValue: "", options: [] });
192
+ expect(vnode.props?.placement).toBeUndefined();
193
+ });
194
+
195
+ it("declares the placement prop (a bottom-pinned surface passes top-*)", () => {
196
+ // Through-wiring is pinned at the contract level: the prop exists
197
+ // with the historic default, and HkSelect's render forwards
198
+ // `placement={props.placement}` into HkSelectPanel.
199
+ expect(HSelect.props!.placement).toBeDefined();
200
+ expect((HSelect.props!.placement as { default: string }).default).toBe("bottom-start");
201
+ });
202
+ });
@@ -10,7 +10,7 @@ import {
10
10
 
11
11
  import { ChevronDown } from "lucide-vue-next";
12
12
  import { useBreakpoint } from "../runtime/useBreakpoint";
13
- import HkSelectPanel from "./HkSelectPanel";
13
+ import HkSelectPanel, { type SelectPanelPlacement } from "./HkSelectPanel";
14
14
  import "./HkSelect.scss";
15
15
 
16
16
  export interface HkSelectOption {
@@ -47,6 +47,19 @@ export default defineComponent({
47
47
  type: Array as PropType<HkSelectOption[]>,
48
48
  default: () => [] as HkSelectOption[],
49
49
  },
50
+ /**
51
+ * Desktop-popout side relative to the trigger (auto-flips when the
52
+ * viewport edge would clip it). The historic default — the form
53
+ * field's drop-DOWN — stays "bottom-start"; a surface pinned to the
54
+ * viewport's bottom band (a dock or a bar) passes "top-*" so the
55
+ * list grows UPWARD, the mirror image of a top-pinned surface
56
+ * growing downward. The mobile sheet always slides from the bottom
57
+ * edge regardless (it is an edge gesture, not an anchored popup).
58
+ */
59
+ placement: {
60
+ type: String as PropType<SelectPanelPlacement>,
61
+ default: "bottom-start",
62
+ },
50
63
  },
51
64
  emits: {
52
65
  "update:modelValue": (_value: string) => true,
@@ -204,7 +217,7 @@ export default defineComponent({
204
217
  onUpdate:open={(v: boolean) => { isOpen.value = v; }}
205
218
  anchorRef={triggerRef.value ?? null}
206
219
  title={props.panelTitle ?? props.label ?? ""}
207
- placement="bottom-start"
220
+ placement={props.placement}
208
221
  offset={4}
209
222
  onKeydown={onPopoutKeydown}
210
223
  >
package/src/index.ts CHANGED
@@ -660,6 +660,7 @@ export { default as HkScrollContainer } from "./components/HkScrollContainer";
660
660
  export { default as HkSearchInput } from "./components/HkSearchInput";
661
661
  export { default as HkSplash } from "./components/HkSplash";
662
662
  export { default as HkSelect } from "./components/HkSelect";
663
+ export { default as HkHeaderPicker, type HkPickerOption } from "./components/HkHeaderPicker";
663
664
  export { default as HkSelectPanel } from "./components/HkSelectPanel";
664
665
  export { default as HkSidebar } from "./components/HkSidebar";
665
666
  export { default as HkSkeleton } from "./components/HkSkeleton";