Skip to content

Commit 5e9d652

Browse files
committed
feat(v1): add phase 1 architecture
1 parent fc9b3b6 commit 5e9d652

29 files changed

Lines changed: 831 additions & 3 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"version": 1,
3+
"lastRunAtMs": 0,
4+
"turnsSinceLastRun": 2,
5+
"lastTranscriptMtimeMs": null,
6+
"lastProcessedGenerationId": "6fb3e935-f232-4e58-817a-1498ba6031d6",
7+
"trialStartedAtMs": null
8+
}

‎src-v1/SelectRoot.spec.ts‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import type { SelectModelValue } from "./types/model";
2+
3+
import { mount } from "@vue/test-utils";
4+
import { describe, expect, it } from "vitest";
5+
import { defineComponent, h, ref } from "vue";
6+
7+
import SelectClear from "./primitives/SelectClear.vue";
8+
import SelectListbox from "./primitives/SelectListbox.vue";
9+
import SelectOption from "./primitives/SelectOption.vue";
10+
import SelectPopover from "./primitives/SelectPopover.vue";
11+
import SelectRoot from "./primitives/SelectRoot.vue";
12+
import SelectTrigger from "./primitives/SelectTrigger.vue";
13+
import SelectValue from "./primitives/SelectValue.vue";
14+
15+
const options = [
16+
{ label: "JavaScript", value: "js" },
17+
{ label: "TypeScript", value: "ts" },
18+
] as const;
19+
20+
function mountPrimitiveSelect(props: {
21+
modelValue?: string | null;
22+
clearable?: boolean;
23+
multiple?: boolean;
24+
} = {}) {
25+
const model = ref<SelectModelValue<string>>(props.modelValue ?? null);
26+
27+
const wrapper = mount(SelectRoot<string>, {
28+
props: {
29+
"modelValue": props.modelValue ?? null,
30+
"clearable": props.clearable ?? false,
31+
"multiple": props.multiple ?? false,
32+
"onUpdate:modelValue": (value: SelectModelValue<string>) => {
33+
model.value = value;
34+
wrapper.setProps({ modelValue: value });
35+
},
36+
},
37+
slots: {
38+
default: () => [
39+
h(SelectTrigger, null, {
40+
default: () => [
41+
h(SelectValue, { placeholder: "Pick a language" }),
42+
h(SelectClear),
43+
],
44+
}),
45+
h(SelectPopover, null, {
46+
default: () => h(SelectListbox, null, {
47+
default: () => options.map((option) =>
48+
h(SelectOption, {
49+
value: option.value,
50+
label: option.label,
51+
}),
52+
),
53+
}),
54+
}),
55+
],
56+
},
57+
});
58+
59+
return { wrapper, model };
60+
}
61+
62+
describe("v1 SelectRoot foundation", () => {
63+
it("renders the root shell and provides context to child primitives", () => {
64+
const { wrapper } = mountPrimitiveSelect();
65+
66+
expect(wrapper.find("[data-v1-select-root]").exists()).toBe(true);
67+
expect(wrapper.find("[data-v1-select-trigger]").exists()).toBe(true);
68+
expect(wrapper.find("[data-v1-select-value]").text()).toBe("Pick a language");
69+
});
70+
71+
it("toggles menu open state from the trigger", async () => {
72+
const { wrapper } = mountPrimitiveSelect();
73+
74+
const trigger = wrapper.get("[data-v1-select-trigger]");
75+
76+
expect(trigger.attributes("aria-expanded")).toBe("false");
77+
expect(wrapper.find("[data-v1-select-popover]").exists()).toBe(false);
78+
79+
await trigger.trigger("click");
80+
81+
expect(trigger.attributes("aria-expanded")).toBe("true");
82+
expect(wrapper.find("[data-v1-select-popover]").exists()).toBe(true);
83+
expect(wrapper.findAll("[data-v1-select-option]")).toHaveLength(2);
84+
});
85+
86+
it("updates v-model on single selection and closes the menu", async () => {
87+
const { wrapper, model } = mountPrimitiveSelect();
88+
89+
await wrapper.get("[data-v1-select-trigger]").trigger("click");
90+
await wrapper.get("[data-v1-select-option][data-value='ts']").trigger("click");
91+
92+
expect(model.value).toBe("ts");
93+
expect(wrapper.get("[data-v1-select-value]").text()).toBe("ts");
94+
expect(wrapper.find("[data-v1-select-popover]").exists()).toBe(false);
95+
});
96+
97+
it("registers declarative options in the collection", async () => {
98+
const { wrapper } = mountPrimitiveSelect();
99+
100+
await wrapper.get("[data-v1-select-trigger]").trigger("click");
101+
102+
expect(wrapper.find("[data-v1-select-no-options]").exists()).toBe(false);
103+
expect(wrapper.findAll("[role='option']")).toHaveLength(2);
104+
});
105+
106+
it("clears the model when clearable is enabled", async () => {
107+
const { wrapper, model } = mountPrimitiveSelect({ modelValue: "js", clearable: true });
108+
109+
expect(wrapper.find("[data-v1-select-clear]").exists()).toBe(true);
110+
111+
await wrapper.get("[data-v1-select-clear]").trigger("click");
112+
113+
expect(model.value).toBeNull();
114+
expect(wrapper.get("[data-v1-select-value]").text()).toBe("Pick a language");
115+
});
116+
117+
it("throws when a primitive is used outside SelectRoot", () => {
118+
const OrphanTrigger = defineComponent({
119+
render: () => h(SelectTrigger),
120+
});
121+
122+
expect(() => mount(OrphanTrigger)).toThrow("injectSelectContext must be used within SelectRoot");
123+
});
124+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<script setup lang="ts" generic="OptionValue extends string | number = string">
2+
import type { SelectModelValue } from "@v1/types/model";
3+
import type { SelectOption as SelectOptionData } from "@v1/types/option";
4+
5+
import SelectClear from "@v1/primitives/SelectClear.vue";
6+
import SelectIndicator from "@v1/primitives/SelectIndicator.vue";
7+
import SelectInput from "@v1/primitives/SelectInput.vue";
8+
import SelectListbox from "@v1/primitives/SelectListbox.vue";
9+
import SelectNoOptions from "@v1/primitives/SelectNoOptions.vue";
10+
import SelectOptionItem from "@v1/primitives/SelectOption.vue";
11+
import SelectPopover from "@v1/primitives/SelectPopover.vue";
12+
import SelectRoot from "@v1/primitives/SelectRoot.vue";
13+
import SelectTrigger from "@v1/primitives/SelectTrigger.vue";
14+
import SelectValue from "@v1/primitives/SelectValue.vue";
15+
16+
type AssembledSelectProps<OptionValue> = {
17+
options: SelectOptionData<OptionValue>[];
18+
placeholder?: string;
19+
multiple?: boolean;
20+
searchable?: boolean;
21+
clearable?: boolean;
22+
disabled?: boolean;
23+
loading?: boolean;
24+
};
25+
26+
withDefaults(defineProps<AssembledSelectProps<OptionValue>>(), {
27+
multiple: false,
28+
searchable: false,
29+
clearable: false,
30+
disabled: false,
31+
loading: false,
32+
});
33+
34+
const model = defineModel<SelectModelValue<OptionValue>>({ default: null });
35+
</script>
36+
37+
<template>
38+
<SelectRoot
39+
v-model="model"
40+
:multiple="multiple"
41+
:searchable="searchable"
42+
:clearable="clearable"
43+
:disabled="disabled"
44+
:loading="loading"
45+
:options="options"
46+
data-v1-assembled-select
47+
>
48+
<SelectTrigger>
49+
<SelectValue :placeholder="placeholder" />
50+
<SelectIndicator />
51+
<SelectClear />
52+
</SelectTrigger>
53+
54+
<SelectPopover>
55+
<SelectInput />
56+
<SelectListbox>
57+
<SelectNoOptions />
58+
<SelectOptionItem
59+
v-for="option in options"
60+
:key="String(option.value)"
61+
:value="option.value"
62+
:label="option.label"
63+
:disabled="option.disabled"
64+
/>
65+
</SelectListbox>
66+
</SelectPopover>
67+
</SelectRoot>
68+
</template>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { computed, shallowRef } from "vue";
2+
3+
export type RegisteredOption<Value> = {
4+
id: string;
5+
value: Value;
6+
label: string;
7+
disabled: boolean;
8+
};
9+
10+
export function useSelectCollection<Value>() {
11+
const registry = shallowRef(new Map<string, RegisteredOption<Value>>());
12+
13+
const register = (option: RegisteredOption<Value>) => {
14+
registry.value.set(option.id, option);
15+
};
16+
17+
const unregister = (id: string) => {
18+
registry.value.delete(id);
19+
};
20+
21+
const registeredOptions = computed(() => [...registry.value.values()]);
22+
23+
return {
24+
register,
25+
unregister,
26+
registeredOptions,
27+
};
28+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { SelectContext } from "../lib/context";
2+
3+
type UseSelectKeyboardParams<OptionValue> = {
4+
context: SelectContext<OptionValue>;
5+
};
6+
7+
/**
8+
* Keyboard navigation for the select collection.
9+
* Full Arrow/Enter/Escape handling lands in a later phase.
10+
*/
11+
export function useSelectKeyboard<OptionValue>(_params: UseSelectKeyboardParams<OptionValue>) {
12+
return {};
13+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { Ref } from "vue";
2+
import type { SelectContext } from "../lib/context";
3+
import type { SelectModelValue } from "../types/model";
4+
import type { useSelectCollection } from "./useSelectCollection";
5+
6+
import { ref } from "vue";
7+
8+
type UseSelectStateParams<OptionValue> = {
9+
modelValue: Ref<SelectModelValue<OptionValue>>;
10+
multiple: Ref<boolean>;
11+
disabled: Ref<boolean>;
12+
searchable: Ref<boolean>;
13+
clearable: Ref<boolean>;
14+
loading: Ref<boolean>;
15+
collection: ReturnType<typeof useSelectCollection<OptionValue>>;
16+
};
17+
18+
export function useSelectState<OptionValue>(params: UseSelectStateParams<OptionValue>) {
19+
const isOpen = ref(false);
20+
const searchValue = ref("");
21+
const activeOptionId = ref<string | null>(null);
22+
23+
const open = () => {
24+
if (params.disabled.value) {
25+
return;
26+
}
27+
28+
isOpen.value = true;
29+
};
30+
31+
const close = () => {
32+
isOpen.value = false;
33+
searchValue.value = "";
34+
activeOptionId.value = null;
35+
};
36+
37+
const toggle = () => {
38+
if (isOpen.value) {
39+
close();
40+
return;
41+
}
42+
43+
open();
44+
};
45+
46+
const select = (value: OptionValue) => {
47+
if (params.disabled.value) {
48+
return;
49+
}
50+
51+
if (params.multiple.value) {
52+
const current = Array.isArray(params.modelValue.value) ? params.modelValue.value : [];
53+
const isAlreadySelected = current.includes(value);
54+
55+
params.modelValue.value = isAlreadySelected
56+
? current.filter((item) => item !== value)
57+
: [...current, value];
58+
59+
return;
60+
}
61+
62+
params.modelValue.value = value;
63+
close();
64+
};
65+
66+
const deselect = (value: OptionValue) => {
67+
if (!params.multiple.value) {
68+
return;
69+
}
70+
71+
const current = Array.isArray(params.modelValue.value) ? params.modelValue.value : [];
72+
73+
params.modelValue.value = current.filter((item) => item !== value);
74+
};
75+
76+
const clear = () => {
77+
if (!params.clearable.value || params.disabled.value) {
78+
return;
79+
}
80+
81+
params.modelValue.value = params.multiple.value ? [] : null;
82+
};
83+
84+
const context: SelectContext<OptionValue> = {
85+
modelValue: params.modelValue,
86+
isOpen,
87+
searchValue,
88+
multiple: params.multiple,
89+
disabled: params.disabled,
90+
searchable: params.searchable,
91+
clearable: params.clearable,
92+
loading: params.loading,
93+
activeOptionId,
94+
registeredOptions: params.collection.registeredOptions,
95+
open,
96+
close,
97+
toggle,
98+
select,
99+
deselect,
100+
clear,
101+
registerOption: params.collection.register,
102+
unregisterOption: params.collection.unregister,
103+
setActiveOptionId: (id) => {
104+
activeOptionId.value = id;
105+
},
106+
};
107+
108+
return { context };
109+
}

‎src-v1/index.spec.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { SelectModelValue, SelectOptionData } from "./index";
2+
3+
import { describe, expect, it } from "vitest";
4+
import {
5+
Select,
6+
7+
SelectOption,
8+
9+
SelectRoot,
10+
} from "./index";
11+
12+
describe("src-v1 internal exports", () => {
13+
it("exports the assembled Select and primitive components", () => {
14+
expect(Select).toBeTruthy();
15+
expect(SelectRoot).toBeTruthy();
16+
expect(SelectOption).toBeTruthy();
17+
});
18+
19+
it("exports v1 option and model types", () => {
20+
const option: SelectOptionData<string> = {
21+
label: "TypeScript",
22+
value: "ts",
23+
};
24+
25+
const model: SelectModelValue<string> = option.value;
26+
27+
expect(option.label).toBe("TypeScript");
28+
expect(model).toBe("ts");
29+
});
30+
});

0 commit comments

Comments
 (0)