@celestia-island/hikari 0.55.170 → 0.55.172
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/HkHeaderPicker.scss +56 -0
- package/src/components/HkHeaderPicker.test.tsx +140 -0
- package/src/components/HkHeaderPicker.tsx +313 -0
- package/src/components/HkSelect.test.tsx +23 -1
- package/src/components/HkSelect.tsx +15 -2
- package/src/components/HkThemePanelContent.tsx +338 -0
- package/src/components/HkThemeToggle.tsx +37 -306
- package/src/components/_menu-item.contract.test.ts +1 -1
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -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
|
+
});
|
|
@@ -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=
|
|
220
|
+
placement={props.placement}
|
|
208
221
|
offset={4}
|
|
209
222
|
onKeydown={onPopoutKeydown}
|
|
210
223
|
>
|