Skip to content

Commit d6ea032

Browse files
fix(onboarding): replace internal boot native selects (#1942)
## Summary This PR replaces the onboarding flow's remaining native dropdowns with the shared `Select` component from `@unraid/ui`, fixes the resulting select handler typing, and aligns the onboarding select trigger styling with the rest of the form in dark mode. The work is targeted at the black-theme report where dropdowns in onboarding were either unreadable for some users or visually inconsistent with adjacent inputs. ## Work Intent Onboarding color fixes. ## Problem The onboarding flow still had a few browser-native `<select>` elements in the internal boot step. That left the open dropdown menu styling up to the browser and operating system, which can produce theme mismatches that are hard to reproduce locally. After moving those controls to the shared `Select` component, another issue became obvious: the shared trigger defaults looked darker than the surrounding onboarding inputs in dark mode, so the controls still felt visually off even though the popup rendering problem was addressed. ## What Changed - replaced the native selects in `OnboardingInternalBootStep` with `@unraid/ui` `Select` - converted slot-count, device, and boot-size options into typed `SelectItemType[]` item lists - preserved existing step behavior by wiring explicit update handlers back into the same refs and validation flow - normalized emitted select values through a shared helper so the onboarding step stays type-safe with the shared component event contract - updated the internal boot step test to stub the shared `Select` component without depending on portal rendering - applied onboarding-specific `Select` trigger styling in `OnboardingInternalBootStep` so internal boot dropdowns match nearby inputs in dark mode - applied the same onboarding-specific `Select` trigger styling in `OnboardingCoreSettingsStep` so timezone, language, and theme selectors match the rest of the onboarding form ## Why This Approach Using the same shared `Select` component already used elsewhere in onboarding gives us control over menu rendering and theme tokens instead of relying on browser-native popup behavior. Keeping the dark-mode styling override local to onboarding lets us make these selects visually match the onboarding form without changing the shared `Select` component globally. ## Verification Passed in this worktree: - `pnpm --dir web type-check` - `pnpm --dir api type-check` - `pnpm --dir web lint` - `pnpm --filter ./api lint` - `pnpm --dir web test` - `pnpm --filter ./api test` - `pnpm --dir web test OnboardingInternalBootStep.test.ts` - `pnpm --dir web test OnboardingCoreSettingsStep.test.ts` ## Notes - dependencies were installed in this worktree so hooks and checks could run normally - the first commit on this branch was created before install and used `--no-verify` because `lint-staged` was unavailable in the fresh worktree at that point - no production app or dev server was started or restarted <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Onboarding boot, device, and preset selections now use a consistent Select component for improved UI consistency and interaction handling. * Select styling consolidated for time zone, language, and theme controls for uniform appearance and focus behavior. * **Tests** * Test harness updated with a functional mock Select that supports item rendering and selection change propagation. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent f0241a8 commit d6ea032

File tree

3 files changed

+108
-33
lines changed

3 files changed

+108
-33
lines changed

web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,28 @@ vi.mock('@unraid/ui', () => ({
6161
template:
6262
'<button data-testid="brand-button" :disabled="disabled" @click="$emit(\'click\')">{{ text }}</button>',
6363
},
64+
Select: {
65+
props: ['modelValue', 'items', 'disabled', 'placeholder'],
66+
emits: ['update:modelValue'],
67+
template: `
68+
<select
69+
data-testid="select"
70+
:disabled="disabled"
71+
:value="modelValue ?? ''"
72+
@change="$emit('update:modelValue', $event.target.value)"
73+
>
74+
<option v-if="placeholder" value="">{{ placeholder }}</option>
75+
<option
76+
v-for="item in items"
77+
:key="item.value"
78+
:value="item.value"
79+
:disabled="item.disabled"
80+
>
81+
{{ item.label }}
82+
</option>
83+
</select>
84+
`,
85+
},
6486
}));
6587

6688
vi.mock('@vue/apollo-composable', () => ({

web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,8 @@ const languageItems = computed(() => {
356356
});
357357
358358
const isLanguageDisabled = computed(() => isLanguagesLoading.value || !!languagesQueryError.value);
359+
const onboardingSelectClasses =
360+
'w-full border-muted bg-bg text-highlighted data-[placeholder]:text-muted focus:ring-primary focus:ring-offset-0';
359361
360362
const handleSubmit = async () => {
361363
if (serverNameValidation.value || serverDescriptionValidation.value) {
@@ -506,7 +508,7 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
506508
v-model="selectedTimeZone"
507509
:items="timeZoneItems"
508510
:placeholder="t('onboarding.coreSettings.selectTimezonePlaceholder')"
509-
class="w-full"
511+
:class="onboardingSelectClasses"
510512
:disabled="isBusy"
511513
size="lg"
512514
/>
@@ -523,7 +525,7 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
523525
:placeholder="
524526
isLanguagesLoading ? t('common.loading') : t('onboarding.coreSettings.selectLanguage')
525527
"
526-
class="w-full"
528+
:class="onboardingSelectClasses"
527529
:disabled="isBusy || isLanguageDisabled"
528530
size="lg"
529531
/>
@@ -582,7 +584,7 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
582584
<Select
583585
v-model="selectedTheme"
584586
:items="themeItems"
585-
class="w-full"
587+
:class="onboardingSelectClasses"
586588
:disabled="isBusy"
587589
size="lg"
588590
/>

web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue

Lines changed: 81 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
ChevronRightIcon,
1111
ExclamationTriangleIcon,
1212
} from '@heroicons/vue/24/solid';
13-
import { BrandButton } from '@unraid/ui';
13+
import { BrandButton, Select } from '@unraid/ui';
1414
import { REFRESH_INTERNAL_BOOT_CONTEXT_MUTATION } from '@/components/Onboarding/graphql/refreshInternalBootContext.mutation';
1515
import { useOnboardingDraftStore } from '@/components/Onboarding/store/onboardingDraft';
1616
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue';
@@ -20,6 +20,7 @@ import type {
2020
OnboardingBootMode,
2121
OnboardingInternalBootSelection,
2222
} from '@/components/Onboarding/store/onboardingDraft';
23+
import type { SelectItemType } from '@unraid/ui';
2324
import type { GetInternalBootContextQuery } from '~/composables/gql/graphql';
2425
2526
import { GetInternalBootContextDocument } from '~/composables/gql/graphql';
@@ -397,6 +398,24 @@ const visiblePresetOptions = computed(() => {
397398
}));
398399
});
399400
401+
const slotCountItems = computed<SelectItemType[]>(() =>
402+
slotOptions.value.map((option) => ({
403+
value: option,
404+
label: String(option),
405+
}))
406+
);
407+
408+
const bootSizePresetItems = computed<SelectItemType[]>(() => [
409+
...visiblePresetOptions.value.map((option) => ({
410+
value: option.value,
411+
label: option.label,
412+
})),
413+
{
414+
value: 'custom',
415+
label: t('onboarding.internalBootStep.bootSize.custom'),
416+
},
417+
]);
418+
400419
const bootSizeMiB = computed(() => {
401420
if (bootSizePreset.value === 'custom') {
402421
const sizeGb = Number.parseInt(customBootSizeGb.value, 10);
@@ -497,6 +516,48 @@ const isDeviceDisabled = (deviceId: string, index: number) => {
497516
);
498517
};
499518
519+
const getDeviceSelectItems = (index: number): SelectItemType[] =>
520+
deviceOptions.value.map((option) => ({
521+
value: option.value,
522+
label: option.label,
523+
disabled: isDeviceDisabled(option.value, index),
524+
}));
525+
526+
const toSelectString = (value: unknown): string => {
527+
if (typeof value === 'string') {
528+
return value;
529+
}
530+
531+
if (typeof value === 'number' || typeof value === 'bigint') {
532+
return String(value);
533+
}
534+
535+
return '';
536+
};
537+
538+
const handleSlotCountChange = (value: unknown) => {
539+
const parsedValue =
540+
typeof value === 'number'
541+
? value
542+
: typeof value === 'bigint'
543+
? Number(value)
544+
: Number.parseInt(toSelectString(value), 10);
545+
if (Number.isFinite(parsedValue) && parsedValue >= 1 && parsedValue <= 2) {
546+
slotCount.value = parsedValue;
547+
}
548+
};
549+
550+
const handleDeviceSelection = (index: number, value: unknown) => {
551+
selectedDevices.value[index] = toSelectString(value);
552+
};
553+
554+
const handleBootSizePresetChange = (value: unknown) => {
555+
bootSizePreset.value = toSelectString(value);
556+
};
557+
558+
const onboardingSelectClasses =
559+
'w-full border-muted bg-bg text-highlighted data-[placeholder]:text-muted focus:ring-primary focus:ring-offset-0';
560+
500561
const buildValidatedSelection = (): OnboardingInternalBootSelection | null => {
501562
const normalizedPoolName = poolName.value.trim();
502563
if (!normalizedPoolName) {
@@ -793,13 +854,13 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
793854
<span class="text-muted text-sm font-medium">
794855
{{ t('onboarding.internalBootStep.fields.slots') }}
795856
</span>
796-
<select
797-
v-model.number="slotCount"
798-
class="border-muted bg-bg focus:ring-primary w-full rounded-md border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
857+
<Select
858+
:model-value="slotCount"
859+
:items="slotCountItems"
860+
:class="onboardingSelectClasses"
799861
:disabled="isBusy"
800-
>
801-
<option v-for="option in slotOptions" :key="option" :value="option">{{ option }}</option>
802-
</select>
862+
@update:model-value="handleSlotCountChange"
863+
/>
803864
</label>
804865
</div>
805866

@@ -811,21 +872,14 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
811872
<label class="text-muted text-sm font-medium">{{
812873
t('onboarding.internalBootStep.fields.deviceSlot', { index })
813874
}}</label>
814-
<select
815-
v-model="selectedDevices[index - 1]"
816-
class="border-muted bg-bg focus:ring-primary w-full rounded-md border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
875+
<Select
876+
:model-value="selectedDevices[index - 1] || undefined"
877+
:items="getDeviceSelectItems(index - 1)"
878+
:placeholder="t('onboarding.internalBootStep.fields.selectDevice')"
879+
:class="onboardingSelectClasses"
817880
:disabled="isBusy"
818-
>
819-
<option value="">{{ t('onboarding.internalBootStep.fields.selectDevice') }}</option>
820-
<option
821-
v-for="option in deviceOptions"
822-
:key="option.value"
823-
:value="option.value"
824-
:disabled="isDeviceDisabled(option.value, index - 1)"
825-
>
826-
{{ option.label }}
827-
</option>
828-
</select>
881+
@update:model-value="handleDeviceSelection(index - 1, $event)"
882+
/>
829883
</div>
830884
</div>
831885

@@ -834,16 +888,13 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
834888
<span class="text-muted text-sm font-medium">
835889
{{ t('onboarding.internalBootStep.fields.bootReservedSize') }}
836890
</span>
837-
<select
838-
v-model="bootSizePreset"
839-
class="border-muted bg-bg focus:ring-primary w-full rounded-md border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
891+
<Select
892+
:model-value="bootSizePreset"
893+
:items="bootSizePresetItems"
894+
:class="onboardingSelectClasses"
840895
:disabled="isBusy"
841-
>
842-
<option v-for="option in visiblePresetOptions" :key="option.value" :value="option.value">
843-
{{ option.label }}
844-
</option>
845-
<option value="custom">{{ t('onboarding.internalBootStep.bootSize.custom') }}</option>
846-
</select>
896+
@update:model-value="handleBootSizePresetChange"
897+
/>
847898
</label>
848899

849900
<label class="space-y-2">

0 commit comments

Comments
 (0)